From 62f2f8483fd02ed71d58eb07a1220a46f1e6b4ce Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Mon, 20 Jul 2026 23:58:53 -0400 Subject: [PATCH] Cloud folder organization, file uploads, appearance themes, title bar rework, faster autosync --- src-tauri/src/lib.rs | 172 +++++++++++++++++-- src-tauri/src/sync.rs | 150 +++++++++++++++- src-tauri/src/workspace.rs | 4 +- src/app.css | 88 ++++++++++ src/lib/components/FileViewer.svelte | 219 ++++++++++++++++++------ src/lib/components/SettingsModal.svelte | 46 ++++- src/lib/ts/api.ts | 58 ++++++- src/lib/ts/state.svelte.ts | 40 ++++- src/routes/+page.svelte | 128 ++++++++++++-- 9 files changed, 808 insertions(+), 97 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 758fe7c..296873c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,7 +89,7 @@ fn update_settings( workspace_root: Option, server_url: Option, autosave_seconds: Option, - sync_minutes: Option, + sync_seconds: Option, ) -> Result { let mut settings = load_settings(&app, &store)?; if let Some(root) = workspace_root { @@ -104,8 +104,8 @@ fn update_settings( if let Some(seconds) = autosave_seconds { settings.autosave_seconds = seconds; } - if let Some(minutes) = sync_minutes { - settings.sync_minutes = minutes; + if let Some(seconds) = sync_seconds { + settings.sync_seconds = seconds; } save_settings(&store, &settings)?; Ok(settings) @@ -745,6 +745,71 @@ fn cloud_list_folders( sync::list_folders(&server_url, &token) } +#[tauri::command] +fn cloud_create_folder( + app: AppHandle, + store: State<'_, Store>, + name: String, + parent_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::create_folder(&server_url, &token, &name, parent_id.as_deref()) +} + +#[tauri::command] +fn cloud_rename_folder( + app: AppHandle, + store: State<'_, Store>, + folder_id: String, + name: String, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::rename_folder(&server_url, &token, &folder_id, &name) +} + +#[tauri::command] +fn cloud_move_folder( + app: AppHandle, + store: State<'_, Store>, + folder_id: String, + parent_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::move_folder(&server_url, &token, &folder_id, parent_id.as_deref()) +} + +#[tauri::command] +fn cloud_delete_folder( + app: AppHandle, + store: State<'_, Store>, + folder_id: String, +) -> Result<(), String> { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::delete_folder(&server_url, &token, &folder_id) +} + +#[tauri::command] +fn cloud_move_project( + app: AppHandle, + store: State<'_, Store>, + cloud_project_id: String, + folder_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::move_cloud_project(&server_url, &token, &cloud_project_id, folder_id.as_deref()) +} + +#[tauri::command] +fn cloud_move_document( + app: AppHandle, + store: State<'_, Store>, + document_id: String, + folder_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::move_document(&server_url, &token, &document_id, folder_id.as_deref()) +} + #[tauri::command] fn cloud_list_documents( app: AppHandle, @@ -793,6 +858,73 @@ fn cloud_delete_file(app: AppHandle, store: State<'_, Store>, file_id: String) - sync::delete_account_file(&server_url, &token, &file_id) } +fn guess_mime_type(name: &str) -> &'static str { + let extension = std::path::Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_lowercase(); + + match extension.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "ttf" => "font/ttf", + "otf" => "font/otf", + "ttc" | "otc" => "font/collection", + "pdf" => "application/pdf", + _ => "application/octet-stream", + } +} + +#[tauri::command] +fn cloud_upload_file( + app: AppHandle, + store: State<'_, Store>, + path: String, + folder_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + let name = std::path::Path::new(&path) + .file_name() + .map(|value| value.to_string_lossy().to_string()) + .ok_or_else(|| format!("'{}' has no file name", path))?; + let data = std::fs::read(&path).map_err(|e| e.to_string())?; + let mime_type = guess_mime_type(&name); + sync::upload_account_file( + &server_url, + &token, + &name, + mime_type, + &data, + folder_id.as_deref(), + ) +} + +#[tauri::command] +fn cloud_rename_file( + app: AppHandle, + store: State<'_, Store>, + file_id: String, + name: String, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::rename_account_file(&server_url, &token, &file_id, name.trim()) +} + +#[tauri::command] +fn cloud_move_file( + app: AppHandle, + store: State<'_, Store>, + file_id: String, + folder_id: Option, +) -> Result { + let (server_url, token) = cloud_credentials(&app, &store)?; + sync::move_account_file(&server_url, &token, &file_id, folder_id.as_deref()) +} + #[tauri::command] fn cloud_delete_document( app: AppHandle, @@ -905,7 +1037,7 @@ fn cloud_create_document( let full = workspace_path(&app, &store, &path)?; let content = std::fs::read_to_string(&full).map_err(|e| e.to_string())?; - let document = sync::create_document(&server_url, &token, title.trim(), &content)?; + let document = sync::create_document(&server_url, &token, title.trim(), &content, None)?; store.save_document_link( &path, @@ -995,13 +1127,23 @@ fn cloud_document_link( } #[tauri::command] -fn cloud_create_project(app: AppHandle, store: State<'_, Store>, name: String) -> Result { +fn cloud_create_project( + app: AppHandle, + store: State<'_, Store>, + name: String, + folder_id: Option, +) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; - sync::create_cloud_project(&server_url, &token, name.trim()) + sync::create_cloud_project(&server_url, &token, name.trim(), folder_id.as_deref()) } #[tauri::command] -fn cloud_new_document(app: AppHandle, store: State<'_, Store>, title: String) -> Result { +fn cloud_new_document( + app: AppHandle, + store: State<'_, Store>, + title: String, + folder_id: Option, +) -> Result { let title = title.trim(); if title.is_empty() { return Err("Document name cannot be empty".to_string()); @@ -1009,7 +1151,7 @@ fn cloud_new_document(app: AppHandle, store: State<'_, Store>, title: String) -> let (server_url, token) = cloud_credentials(&app, &store)?; let content = format!("= {}\n\nStart writing here.\n", title); - sync::create_document(&server_url, &token, title, &content) + sync::create_document(&server_url, &token, title, &content, folder_id.as_deref()) } #[tauri::command] @@ -1024,9 +1166,10 @@ fn cloud_clone_project( store: State<'_, Store>, cloud_project_id: String, project_name: String, + parent: String, ) -> Result { let (server_url, token) = cloud_credentials(&app, &store)?; - let project = project_name.trim().to_string(); + let project = join_path(&parent, project_name.trim()); let dir = workspace_path(&app, &store, &project)?; if dir.exists() { return Err(format!("A project named '{}' already exists", project_name)); @@ -1054,7 +1197,7 @@ fn cloud_link_project( let cloud_project_id = match cloud_project_id { Some(id) if !id.trim().is_empty() => id, - _ => sync::create_cloud_project(&server_url, &token, &project)?.id, + _ => sync::create_cloud_project(&server_url, &token, &project, None)?.id, }; meta.cloud_project_id = Some(cloud_project_id); @@ -1196,11 +1339,20 @@ pub fn run() { cloud_account, cloud_list_projects, cloud_list_folders, + cloud_create_folder, + cloud_rename_folder, + cloud_move_folder, + cloud_delete_folder, + cloud_move_project, + cloud_move_document, cloud_list_documents, cloud_list_shared, cloud_list_files, cloud_download_file, cloud_delete_file, + cloud_upload_file, + cloud_rename_file, + cloud_move_file, cloud_download_document, cloud_delete_document, cloud_sync_document, diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index fe7cf5b..0c0731b 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -181,6 +181,7 @@ pub struct ProjectSummary { pub id: String, pub name: String, pub entrypoint: String, + pub folder_id: Option, pub role: String, pub updated_at: String, } @@ -199,11 +200,12 @@ pub fn create_cloud_project( server_url: &str, token: &str, name: &str, + folder_id: Option<&str>, ) -> Result { agent() .post(&endpoint(server_url, "/projects")) .set("Authorization", &format!("Bearer {}", token)) - .send_json(ureq::json!({ "name": name })) + .send_json(ureq::json!({ "name": name, "folder_id": folder_id })) .map_err(describe)? .into_json::() .map_err(|e| e.to_string()) @@ -222,6 +224,21 @@ pub fn delete_cloud_project( Ok(()) } +pub fn move_cloud_project( + server_url: &str, + token: &str, + cloud_project_id: &str, + folder_id: Option<&str>, +) -> Result { + agent() + .patch(&endpoint(server_url, &format!("/projects/{}", cloud_project_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "folder_id": folder_id })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + #[derive(Deserialize)] pub struct ManifestEntry { pub path: String, @@ -750,6 +767,66 @@ pub fn list_folders(server_url: &str, token: &str) -> Result, S .map_err(|e| e.to_string()) } +pub fn create_folder( + server_url: &str, + token: &str, + name: &str, + parent_id: Option<&str>, +) -> Result { + agent() + .post(&endpoint(server_url, "/folders")) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ + "name": name, + "parent_id": parent_id, + })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn rename_folder( + server_url: &str, + token: &str, + folder_id: &str, + name: &str, +) -> Result { + agent() + .patch(&endpoint(server_url, &format!("/folders/{}", folder_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "name": name })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn move_folder( + server_url: &str, + token: &str, + folder_id: &str, + parent_id: Option<&str>, +) -> Result { + agent() + .patch(&endpoint( + server_url, + &format!("/folders/{}/move", folder_id), + )) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "parent_id": parent_id })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn delete_folder(server_url: &str, token: &str, folder_id: &str) -> Result<(), String> { + agent() + .delete(&endpoint(server_url, &format!("/folders/{}", folder_id))) + .set("Authorization", &format!("Bearer {}", token)) + .call() + .map_err(describe)?; + Ok(()) +} + pub fn list_documents( server_url: &str, token: &str, @@ -808,6 +885,7 @@ pub fn create_document( token: &str, title: &str, content: &str, + folder_id: Option<&str>, ) -> Result { agent() .post(&endpoint(server_url, "/documents")) @@ -815,13 +893,28 @@ pub fn create_document( .send_json(ureq::json!({ "title": title, "content": content, - "folder_id": null, + "folder_id": folder_id, })) .map_err(describe)? .into_json::() .map_err(|e| e.to_string()) } +pub fn move_document( + server_url: &str, + token: &str, + document_id: &str, + folder_id: Option<&str>, +) -> Result { + agent() + .patch(&endpoint(server_url, &format!("/documents/{}", document_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "folder_id": folder_id })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + pub fn sync_document( server_url: &str, token: &str, @@ -1060,6 +1153,29 @@ pub fn list_account_files( .map_err(|e| e.to_string()) } +pub fn upload_account_file( + server_url: &str, + token: &str, + name: &str, + mime_type: &str, + data: &[u8], + folder_id: Option<&str>, +) -> Result { + agent() + .post(&endpoint(server_url, "/files")) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ + "name": name, + "mime_type": mime_type, + "encoding": "base64", + "content": BASE64.encode(data), + "folder_id": folder_id, + })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + pub fn pull_account_file( server_url: &str, token: &str, @@ -1082,3 +1198,33 @@ pub fn delete_account_file(server_url: &str, token: &str, file_id: &str) -> Resu .map_err(describe)?; Ok(()) } + +pub fn rename_account_file( + server_url: &str, + token: &str, + file_id: &str, + name: &str, +) -> Result { + agent() + .patch(&endpoint(server_url, &format!("/files/{}", file_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "name": name })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} + +pub fn move_account_file( + server_url: &str, + token: &str, + file_id: &str, + folder_id: Option<&str>, +) -> Result { + agent() + .patch(&endpoint(server_url, &format!("/files/{}/move", file_id))) + .set("Authorization", &format!("Bearer {}", token)) + .send_json(ureq::json!({ "folder_id": folder_id })) + .map_err(describe)? + .into_json::() + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 1759c9d..6b87dee 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -68,7 +68,7 @@ pub struct Settings { #[serde(default)] pub autosave_seconds: u32, #[serde(default)] - pub sync_minutes: u32, + pub sync_seconds: u32, } impl Settings { @@ -84,7 +84,7 @@ impl Settings { account_email: None, account_username: None, autosave_seconds: 5, - sync_minutes: 0, + sync_seconds: 0, } } } diff --git a/src/app.css b/src/app.css index 2906795..2bf24c0 100644 --- a/src/app.css +++ b/src/app.css @@ -31,6 +31,94 @@ --color-success: #4cc47f; } +:root[data-color-theme="slate"] { + --color-surface: #ffffff; + --color-surface-muted: #f2f5f7; + --color-surface-sunken: #e6ebef; + --color-line: #d7dee4; + --color-ink: #12181f; + --color-ink-muted: #5a6672; + --color-accent: #0f9b8e; + --color-accent-soft: #e1f5f2; +} + +:root[data-color-theme="slate"][data-theme="dark"] { + --color-surface: #12181e; + --color-surface-muted: #182027; + --color-surface-sunken: #1f2830; + --color-line: #2b3640; + --color-ink: #eef2f5; + --color-ink-muted: #8b98a5; + --color-accent: #3fc2b3; + --color-accent-soft: #163330; +} + +:root[data-color-theme="sunset"] { + --color-surface: #fffdf9; + --color-surface-muted: #faf3e9; + --color-surface-sunken: #f3e6d3; + --color-line: #e7d5b8; + --color-ink: #241a10; + --color-ink-muted: #7a6650; + --color-accent: #e8623f; + --color-accent-soft: #fbe4dc; +} + +:root[data-color-theme="sunset"][data-theme="dark"] { + --color-surface: #1f1712; + --color-surface-muted: #261c15; + --color-surface-sunken: #2f241a; + --color-line: #3d2f22; + --color-ink: #f7ede1; + --color-ink-muted: #b9a48c; + --color-accent: #f4805c; + --color-accent-soft: #3a2419; +} + +:root[data-color-theme="forest"] { + --color-surface: #fbfdfb; + --color-surface-muted: #eef5ee; + --color-surface-sunken: #dfebe0; + --color-line: #c9dccb; + --color-ink: #12201a; + --color-ink-muted: #57685c; + --color-accent: #2f9457; + --color-accent-soft: #dcf0e2; +} + +:root[data-color-theme="forest"][data-theme="dark"] { + --color-surface: #121a15; + --color-surface-muted: #17211b; + --color-surface-sunken: #1e2c22; + --color-line: #2b3c30; + --color-ink: #e9f3ec; + --color-ink-muted: #8fa896; + --color-accent: #4fbf7c; + --color-accent-soft: #1a3324; +} + +:root[data-color-theme="grape"] { + --color-surface: #fdfbff; + --color-surface-muted: #f4eefb; + --color-surface-sunken: #e8daf5; + --color-line: #d7c3ec; + --color-ink: #1c1526; + --color-ink-muted: #6a5d7c; + --color-accent: #8b47d6; + --color-accent-soft: #f0e2fb; +} + +:root[data-color-theme="grape"][data-theme="dark"] { + --color-surface: #17121e; + --color-surface-muted: #1d1725; + --color-surface-sunken: #251d30; + --color-line: #362a42; + --color-ink: #f1eaf7; + --color-ink-muted: #a998b8; + --color-accent: #b47af0; + --color-accent-soft: #2e2140; +} + :root[data-contrast="high"] { --color-line: #9aa0ab; --color-ink-muted: #33363c; diff --git a/src/lib/components/FileViewer.svelte b/src/lib/components/FileViewer.svelte index 5444fe4..8addaa0 100644 --- a/src/lib/components/FileViewer.svelte +++ b/src/lib/components/FileViewer.svelte @@ -1,7 +1,7 @@