From 587fcb3c4311ac19b8e957ed3e146da5b49ff7bb Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Sun, 28 Jun 2026 11:38:05 -0500 Subject: [PATCH] saving might work idk --- .vscode/settings.json | 2 +- app/write/new/page.tsx | 36 +++++++++++-- components/text-editor/index.tsx | 89 ++++++++++++++++++++++---------- src-tauri/src/db/mod.rs | 84 ++++++++++++++++++++++++++++++ src-tauri/src/db/models.rs | 4 +- src-tauri/src/lib.rs | 24 ++++++--- 6 files changed, 198 insertions(+), 41 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a6fbe9d..93e8743 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Homelab", "tauri"] + "cSpell.words": ["Homelab", "Insertable", "tauri"] } diff --git a/app/write/new/page.tsx b/app/write/new/page.tsx index 915d501..e7dccb1 100644 --- a/app/write/new/page.tsx +++ b/app/write/new/page.tsx @@ -6,6 +6,13 @@ import { invoke } from "@tauri-apps/api/core"; import TextEditor from "@/components/text-editor"; import { HomelabApp } from "@/types"; +// Create a type for the Blog returned by your Rust backend +interface Blog { + id: string; + title: string; + content: string; +} + function WritePageContent() { const searchParams = useSearchParams(); const appQuery = searchParams.get("app"); @@ -13,6 +20,9 @@ function WritePageContent() { const [initialAppIds, setInitialAppIds] = useState([]); const [loading, setLoading] = useState(true); + // Track the document ID to allow updating instead of duplicating + const [documentId, setDocumentId] = useState(null); + useEffect(() => { const checkQueryParam = async () => { if (!appQuery) { @@ -23,7 +33,6 @@ function WritePageContent() { try { const apps = (await invoke("get_app")) as HomelabApp[]; - // Match checking both UUID structure and sanitized string names const matchedApp = apps.find( (app) => app.id === appQuery || @@ -46,9 +55,27 @@ function WritePageContent() { checkQueryParam(); }, [appQuery]); - const onSave = (content: string, linkedAppIds: string[]) => { - console.log("Document Rich Text: ", content); - console.log("Document Bound App IDs: ", linkedAppIds); + // Updated onSave signature matching what TextEditor emits + const onSave = async ( + content: string, + linkedAppIds: string[], + title: string, + ) => { + try { + const savedBlog = await invoke("save_blog", { + id: documentId, + title: title || "Untitled Document", + content: content, + linkedAppIds: linkedAppIds, + }); + + // Update state with the backend-generated/returned ID + if (savedBlog && savedBlog.id) { + setDocumentId(savedBlog.id); + } + } catch (error) { + console.error("Failed to save document:", error); + } }; if (loading) { @@ -66,7 +93,6 @@ function WritePageContent() { ); } -// 2. Main Page export wrapped in a Suspense boundary export default function WritePage() { return (
diff --git a/components/text-editor/index.tsx b/components/text-editor/index.tsx index 81d98c3..16b9933 100644 --- a/components/text-editor/index.tsx +++ b/components/text-editor/index.tsx @@ -8,11 +8,12 @@ import { Markdown } from "@tiptap/markdown"; import { HomelabApp } from "@/types"; import { invoke } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; -import Menubar from "./menu-bar"; // Added menubar import back +import Menubar from "./menu-bar"; interface TextEditorProps { content: string; - onSave: (content: string, linkedAppIds: string[]) => void; + initialTitle?: string; + onSave: (content: string, linkedAppIds: string[], title: string) => void; initialLinkedAppIds?: string[]; } @@ -22,14 +23,21 @@ const getApps = async (): Promise => { export default function TextEditor({ content, + initialTitle = "", onSave, initialLinkedAppIds = [], }: TextEditorProps) { const [availableApps, setAvailableApps] = useState([]); const [linkedAppIds, setLinkedAppIds] = useState(initialLinkedAppIds); + // Title control state + const [title, setTitle] = useState(initialTitle); + // Save control + // Will be saved on completed handleSave, error if func fails, and saving if the func is in progress + const [isSaved, setIsSaved] = useState<"saved" | "error" | "saving">( + "saving", + ); - // 🔄 FIX: Watch for updates to initialLinkedAppIds once fetched from the URL useEffect(() => { if (initialLinkedAppIds.length > 0) { setLinkedAppIds(initialLinkedAppIds); @@ -44,8 +52,6 @@ export default function TextEditor({ fetchApps(); }, []); - // ... rest of your TextEditor component stays the same - const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -57,7 +63,7 @@ export default function TextEditor({ Markdown, ], immediatelyRender: false, - content: content || "

Start typing your documentation notes here...

", + content, editorProps: { attributes: { class: "prose max-w-none focus:outline-none min-h-[300px]", @@ -66,32 +72,64 @@ export default function TextEditor({ }); const toggleAppLink = (appId: string) => { - setLinkedAppIds( - (prev) => - prev.includes(appId) - ? prev.filter((id) => id !== appId) // Unlink if already added - : [...prev, appId], // Link if not added + setLinkedAppIds((prev) => + prev.includes(appId) + ? prev.filter((id) => id !== appId) + : [...prev, appId], ); }; const handleSave = () => { - if (!editor) return; - const htmlOutput = editor.getHTML(); - // Pass both the layout structure and the entire linked app set - onSave(htmlOutput, linkedAppIds); + setIsSaved("saving"); + try { + if (!editor) return; + const htmlOutput = editor.getHTML(); + // Dispatch text body layout, assets collection array, and document title string + onSave(htmlOutput, linkedAppIds, title); + setIsSaved("saved"); + } catch (error) { + console.error("Error saving document:", error); + setIsSaved("error"); + } }; + // create debounced save, every 15 seconds + useEffect(() => { + const interval = setInterval(() => { + handleSave(); + }, 15000); + + return () => clearInterval(interval); + }, [editor, linkedAppIds, title]); + return (
- {/* Top action bar */} -
- Draft Session - + {/* Top action bar updated with live Title Input */} +
+ setTitle(e.target.value)} + placeholder="Untitled Document..." + className="bg-transparent text-zinc-800 font-semibold text-base placeholder-zinc-300 focus:outline-none flex-1 py-1" + /> +
+ {isSaved === "saving" && ( + Saving... + )} + {isSaved === "saved" && ( + Saved + )} + {isSaved === "error" && ( + Error saving + )} + +
{/* Editor Controls */} @@ -129,9 +167,8 @@ export default function TextEditor({ : "bg-white border-zinc-200 hover:border-zinc-300" }`} > - {/* Subtle active border indicator */} {isLinked && ( -
+
)}
usize { .execute(conn) .expect("Error deleting app") } + +pub fn create_blog_db( + conn: &mut SqliteConnection, + title: &str, + content: &str, + linked_app_ids: Option<&[String]>, +) -> models::Blog { + use crate::db::schema::blog; + + // json seralize the referenced app ids, store as string + if let Some(ids) = linked_app_ids { + for id in ids { + // Check if the app exists in the database + let app_exists = crate::db::schema::app::dsl::app + .filter(crate::db::schema::app::dsl::id.eq(id)) + .first::(conn) + .optional() + .expect("Error checking if app exists"); + + if app_exists.is_none() { + panic!("App with id {} does not exist", id); + } + } + } + + // if there are linked app ids, serialize them to json, else set to None + let reference: Option = if let Some(ids) = linked_app_ids { + Some(serde_json::to_string(&ids).expect("Failed to convert to JSON")) + } else { + None + }; + + let new_blog = models::Blog { + id: uuid::Uuid::new_v4().to_string(), + title: title.to_string(), + content: content.to_string(), + reference, + }; + + diesel::insert_into(blog::table) + .values(&new_blog) + .execute(conn) + .expect("Error saving new blog"); + + new_blog +} + +// Will be used to update an existing blog post OR create a new one if it doesn't exist +pub fn save_blog( + conn: &mut SqliteConnection, + blog_id: Option<&str>, + title: &str, + content: &str, + linked_app_ids: Option<&[String]>, +) -> models::Blog { + use crate::db::schema::blog; + + // json seralize the referenced app ids, store as string + let reference: Option = if let Some(ids) = linked_app_ids { + Some(serde_json::to_string(&ids).expect("Failed to convert to JSON")) + } else { + None + }; + + if let Some(id) = blog_id { + // Update existing blog + diesel::update(blog::table.filter(blog::id.eq(id))) + .set(( + blog::title.eq(title), + blog::content.eq(content), + blog::reference.eq(reference), + )) + .execute(conn) + .expect("Error updating blog"); + + blog::table + .filter(blog::id.eq(id)) + .first::(conn) + .expect("Error loading updated blog") + } else { + // Create new blog + create_blog_db(conn, title, content, linked_app_ids) + } +} diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 5c40e29..d98c80e 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -12,7 +12,7 @@ pub struct App { pub ssh_address: Option, // default to normal address } -#[derive(Queryable, Selectable, Debug, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Debug, Insertable, Serialize, Deserialize)] #[diesel(table_name = crate::db::schema::blog)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Blog { @@ -22,7 +22,7 @@ pub struct Blog { pub reference: Option, // uuid } -#[derive(Queryable, Selectable, Debug, Serialize, Deserialize)] +#[derive(Queryable, Selectable, Debug, Insertable, Serialize, Deserialize)] #[diesel(table_name = crate::db::schema::identities)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Identity { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7861d6b..ba7f775 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,14 +27,12 @@ async fn ping_target(address: &str) -> bool { let ip_target: IpAddr = socket_addr.ip(); - // 2. Configure unprivileged ICMP requestor let timeout = Some(Duration::from_secs(2)); let pinger = match IcmpEchoRequestor::new(ip_target, None, None, timeout) { Ok(p) => p, Err(_) => return false, }; - // 3. Send ICMP Echo request asynchronously match pinger.send().await { Ok(reply) => { // Check if the status returned is a valid success response @@ -46,7 +44,7 @@ async fn ping_target(address: &str) -> bool { #[tauri::command] fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Option<&str>) -> App { - let mut conn = &mut establish_connection(); + let mut conn = establish_connection(); let app = create_app(&mut conn, name, address, description, ssh_address); println!("{:#?}", app); @@ -55,7 +53,7 @@ fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Op #[tauri::command] fn get_app() -> Vec { - let mut conn = &mut establish_connection(); + let mut conn = establish_connection(); let apps = get_apps(&mut conn); println!("{:#?}", apps); @@ -64,8 +62,8 @@ fn get_app() -> Vec { #[tauri::command] fn delete_apps(id: &str) { - let conn = &mut establish_connection(); - delete_app(conn, id); + let mut conn = establish_connection(); + delete_app(&mut conn, id); } #[tauri::command] @@ -76,6 +74,17 @@ async fn ping(address: &str) -> Result { } } +#[tauri::command] +fn save_blog( + id: Option<&str>, + title: &str, + content: &str, + linked_app_ids: Option>, +) -> db::models::Blog { + let mut conn = establish_connection(); + db::save_blog(&mut conn, id, title, content, linked_app_ids.as_deref()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -84,7 +93,8 @@ pub fn run() { new_app, get_app, delete_apps, - ping + ping, + save_blog ]) .run(tauri::generate_context!()) .expect("error while running tauri application");