| use base64::{engine::general_purpose, Engine as _};
|
| use serde::{Deserialize, Serialize};
|
| use std::sync::Mutex;
|
| use tauri::{AppHandle, Manager};
|
| use uuid::Uuid;
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct BoardItem {
|
| pub id: String,
|
| pub kind: String,
|
| pub library_id: Option<String>,
|
| pub data_url: Option<String>,
|
| pub text: Option<String>,
|
| pub colors: Vec<String>,
|
| pub x: f64,
|
| pub y: f64,
|
| pub w: f64,
|
| pub h: f64,
|
| pub z: i64,
|
| }
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct BoardSummary {
|
| pub id: String,
|
| pub title: String,
|
| pub item_count: usize,
|
| pub created_at: i64,
|
| pub updated_at: i64,
|
| }
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct BoardDocument {
|
| pub id: String,
|
| pub title: String,
|
| pub items: Vec<BoardItem>,
|
| pub created_at: i64,
|
| pub updated_at: i64,
|
| }
|
|
|
| impl Default for BoardDocument {
|
| fn default() -> Self {
|
| let now = chrono::Utc::now().timestamp();
|
| Self { id: "default".into(), title: "Reference Board".into(), items: vec![], created_at: now, updated_at: now }
|
| }
|
| }
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
| struct BoardIndex { boards: Vec<BoardSummary>, active_id: Option<String> }
|
|
|
| #[derive(Default)]
|
| pub struct BoardState { pub current: Mutex<Option<BoardDocument>> }
|
|
|
| fn board_file(id: &str) -> String { format!("board_{id}.json") }
|
| fn save_index(app: &AppHandle, index: &BoardIndex) -> Result<(), String> { crate::persistence::save_json(app, "boards_index.json", index) }
|
| fn load_index(app: &AppHandle) -> Result<BoardIndex, String> { crate::persistence::load_json(app, "boards_index.json") }
|
| fn save_doc(app: &AppHandle, doc: &BoardDocument) -> Result<(), String> { crate::persistence::save_json(app, &board_file(&doc.id), doc) }
|
| fn load_doc(app: &AppHandle, id: &str) -> Result<BoardDocument, String> { crate::persistence::load_json(app, &board_file(id)) }
|
|
|
| fn update_index_for_doc(app: &AppHandle, doc: &BoardDocument, make_active: bool) -> Result<(), String> {
|
| let mut index = load_index(app).unwrap_or_default();
|
| let summary = BoardSummary { id: doc.id.clone(), title: doc.title.clone(), item_count: doc.items.len(), created_at: doc.created_at, updated_at: doc.updated_at };
|
| if let Some(slot) = index.boards.iter_mut().find(|b| b.id == doc.id) { *slot = summary; } else { index.boards.insert(0, summary); }
|
| if make_active { index.active_id = Some(doc.id.clone()); }
|
| save_index(app, &index)
|
| }
|
|
|
| fn ensure_current(app: &AppHandle) -> Result<BoardDocument, String> {
|
| let state = app.state::<BoardState>();
|
| if let Some(doc) = state.current.lock().map_err(|_| "board lock poisoned".to_string())?.clone() { return Ok(doc); }
|
| let index = load_index(app).unwrap_or_default();
|
| if let Some(active) = index.active_id.clone().or_else(|| index.boards.first().map(|b| b.id.clone())) {
|
| let doc = load_doc(app, &active).unwrap_or_default();
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| return Ok(doc);
|
| }
|
| let doc = BoardDocument::default();
|
| save_doc(app, &doc)?;
|
| let mut idx = load_index(app).unwrap_or_default();
|
| idx.boards.push(BoardSummary { id: doc.id.clone(), title: doc.title.clone(), item_count: 0, created_at: doc.created_at, updated_at: doc.updated_at });
|
| idx.active_id = Some(doc.id.clone());
|
| save_index(app, &idx)?;
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| Ok(doc)
|
| }
|
|
|
| fn save_current(app: &AppHandle, mut doc: BoardDocument) -> Result<BoardDocument, String> {
|
| doc.updated_at = chrono::Utc::now().timestamp();
|
| save_doc(app, &doc)?;
|
| update_index_for_doc(app, &doc, true)?;
|
| let state = app.state::<BoardState>();
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| Ok(doc)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_list(app: AppHandle) -> Result<Vec<BoardSummary>, String> { Ok(load_index(&app).unwrap_or_default().boards) }
|
| #[tauri::command]
|
| pub fn board_current(app: AppHandle) -> Result<BoardDocument, String> { ensure_current(&app) }
|
|
|
| #[tauri::command]
|
| pub fn board_create(app: AppHandle, title: String) -> Result<BoardDocument, String> {
|
| let now = chrono::Utc::now().timestamp();
|
| let doc = BoardDocument { id: Uuid::new_v4().to_string(), title: if title.trim().is_empty() { "Untitled Board".into() } else { title }, items: vec![], created_at: now, updated_at: now };
|
| save_doc(&app, &doc)?; update_index_for_doc(&app, &doc, true)?;
|
| let state = app.state::<BoardState>();
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| Ok(doc)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_open(app: AppHandle, id: String) -> Result<BoardDocument, String> {
|
| let doc = load_doc(&app, &id)?; update_index_for_doc(&app, &doc, true)?;
|
| let state = app.state::<BoardState>();
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| Ok(doc)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_save_as(app: AppHandle, title: String) -> Result<BoardDocument, String> {
|
| let current = ensure_current(&app)?;
|
| let now = chrono::Utc::now().timestamp();
|
| let doc = BoardDocument { id: Uuid::new_v4().to_string(), title: if title.trim().is_empty() { format!("{} copy", current.title) } else { title }, items: current.items, created_at: now, updated_at: now };
|
| save_doc(&app, &doc)?; update_index_for_doc(&app, &doc, true)?;
|
| let state = app.state::<BoardState>();
|
| *state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
|
| Ok(doc)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_load(app: AppHandle) -> Result<Vec<BoardItem>, String> { Ok(ensure_current(&app)?.items) }
|
| #[tauri::command]
|
| pub fn board_items(app: AppHandle) -> Result<Vec<BoardItem>, String> { board_load(app) }
|
|
|
| #[tauri::command]
|
| pub fn board_add_image(app: AppHandle, library_id: Option<String>, data_url: String, x: f64, y: f64, w: f64, h: f64) -> Result<BoardItem, String> {
|
| let mut doc = ensure_current(&app)?;
|
| let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "image".into(), library_id, data_url: Some(data_url), text: None, colors: vec![], x, y, w, h, z: chrono::Utc::now().timestamp_millis() };
|
| doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_add_note(app: AppHandle, text: String, x: f64, y: f64) -> Result<BoardItem, String> {
|
| let mut doc = ensure_current(&app)?;
|
| let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "note".into(), library_id: None, data_url: None, text: Some(text), colors: vec![], x, y, w: 240.0, h: 160.0, z: chrono::Utc::now().timestamp_millis() };
|
| doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_add_palette(app: AppHandle, colors: Vec<String>, x: f64, y: f64) -> Result<BoardItem, String> {
|
| if colors.is_empty() { return Err("no colors to add".into()); }
|
| let mut doc = ensure_current(&app)?;
|
| let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "palette".into(), library_id: None, data_url: None, text: None, colors, x, y, w: 260.0, h: 90.0, z: chrono::Utc::now().timestamp_millis() };
|
| doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
|
| }
|
|
|
| #[tauri::command]
|
| pub async fn board_extract_palette_from_item(app: AppHandle, id: String, count: Option<usize>) -> Result<Vec<String>, String> {
|
| let mut doc = ensure_current(&app)?;
|
| let item = doc.items.iter_mut().find(|i| i.id == id).ok_or("board item not found")?;
|
| let src = item.data_url.clone().ok_or("board item has no image source")?;
|
| let bytes = image_bytes_from_source(&src).await?;
|
| let img = image::load_from_memory(&bytes).map_err(|e| format!("unsupported image: {e}"))?;
|
| let colors = extract_palette(&img, count.unwrap_or(6).clamp(1, 12));
|
| item.colors = colors.clone();
|
| save_current(&app, doc)?; Ok(colors)
|
| }
|
|
|
| #[tauri::command]
|
| pub async fn board_add_palette_from_item(app: AppHandle, id: String, x: f64, y: f64) -> Result<BoardItem, String> {
|
| let colors = board_extract_palette_from_item(app.clone(), id, Some(6)).await?;
|
| board_add_palette(app, colors, x, y)
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_update_item(app: AppHandle, item: BoardItem) -> Result<(), String> {
|
| let mut doc = ensure_current(&app)?;
|
| if let Some(slot) = doc.items.iter_mut().find(|i| i.id == item.id) { *slot = item; }
|
| save_current(&app, doc).map(|_| ())
|
| }
|
|
|
| #[tauri::command]
|
| pub fn board_delete_item(app: AppHandle, id: String) -> Result<(), String> {
|
| let mut doc = ensure_current(&app)?;
|
| doc.items.retain(|i| i.id != id);
|
| save_current(&app, doc).map(|_| ())
|
| }
|
|
|
| async fn image_bytes_from_source(src: &str) -> Result<Vec<u8>, String> {
|
| if let Some((_, encoded)) = src.split_once(",") {
|
| if src.starts_with("data:image/") { return general_purpose::STANDARD.decode(encoded).map_err(|e| e.to_string()); }
|
| }
|
| if src.starts_with("http://") || src.starts_with("https://") {
|
| return Ok(reqwest::get(src).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?.to_vec());
|
| }
|
| Err("unsupported image source".into())
|
| }
|
|
|
| fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
|
| let thumb = img.thumbnail(120, 120).to_rgb8();
|
| let mut pixels: Vec<[u8; 3]> = thumb.pixels().filter_map(|p| {
|
| let [r, g, b] = [p[0], p[1], p[2]];
|
| if r.max(g).max(b) < 12 { None } else { Some([r, g, b]) }
|
| }).collect();
|
| if pixels.is_empty() { pixels = thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect(); }
|
| let mut buckets = vec![pixels];
|
| while buckets.len() < count {
|
| let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i, b)| {
|
| let ranges: Vec<u8> = (0..3).map(|c| b.iter().map(|p| p[c]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[c]).min().unwrap_or(0))).collect();
|
| let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a, _)| a).unwrap_or(0);
|
| (i, axis)
|
| }).max_by_key(|(i, axis)| {
|
| let b = &buckets[*i];
|
| b.iter().map(|p| p[*axis]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[*axis]).min().unwrap_or(0))
|
| }) else { break; };
|
| let mut b = buckets.remove(idx); b.sort_by_key(|p| p[axis]); let right = b.split_off(b.len() / 2); buckets.push(b); buckets.push(right);
|
| }
|
| buckets.into_iter().filter(|b| !b.is_empty()).map(|b| {
|
| let n = b.len() as u32;
|
| let (r, g, bl) = b.iter().fold((0u32, 0u32, 0u32), |a, p| (a.0 + p[0] as u32, a.1 + p[1] as u32, a.2 + p[2] as u32));
|
| format!("#{:02X}{:02X}{:02X}", r / n, g / n, bl / n)
|
| }).collect()
|
| }
|
|
|