WIP: blogging. you have a markdown editor with linking capabilities, along with the ability to view, edit, and create other blogs. #1

Draft
pure_sagacity wants to merge 5 commits from blogging into main
6 changed files with 198 additions and 41 deletions
Showing only changes of commit 587fcb3c43 - Show all commits
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"cSpell.words": ["Homelab", "tauri"] "cSpell.words": ["Homelab", "Insertable", "tauri"]
} }
+31 -5
View File
@@ -6,6 +6,13 @@ import { invoke } from "@tauri-apps/api/core";
import TextEditor from "@/components/text-editor"; import TextEditor from "@/components/text-editor";
import { HomelabApp } from "@/types"; 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() { function WritePageContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const appQuery = searchParams.get("app"); const appQuery = searchParams.get("app");
@@ -13,6 +20,9 @@ function WritePageContent() {
const [initialAppIds, setInitialAppIds] = useState<string[]>([]); const [initialAppIds, setInitialAppIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Track the document ID to allow updating instead of duplicating
const [documentId, setDocumentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const checkQueryParam = async () => { const checkQueryParam = async () => {
if (!appQuery) { if (!appQuery) {
@@ -23,7 +33,6 @@ function WritePageContent() {
try { try {
const apps = (await invoke("get_app")) as HomelabApp[]; const apps = (await invoke("get_app")) as HomelabApp[];
// Match checking both UUID structure and sanitized string names
const matchedApp = apps.find( const matchedApp = apps.find(
(app) => (app) =>
app.id === appQuery || app.id === appQuery ||
@@ -46,9 +55,27 @@ function WritePageContent() {
checkQueryParam(); checkQueryParam();
}, [appQuery]); }, [appQuery]);
const onSave = (content: string, linkedAppIds: string[]) => { // Updated onSave signature matching what TextEditor emits
console.log("Document Rich Text: ", content); const onSave = async (
console.log("Document Bound App IDs: ", linkedAppIds); content: string,
linkedAppIds: string[],
title: string,
) => {
try {
const savedBlog = await invoke<Blog>("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) { if (loading) {
@@ -66,7 +93,6 @@ function WritePageContent() {
); );
} }
// 2. Main Page export wrapped in a Suspense boundary
export default function WritePage() { export default function WritePage() {
return ( return (
<div className="flex flex-col p-2"> <div className="flex flex-col p-2">
+63 -26
View File
@@ -8,11 +8,12 @@ import { Markdown } from "@tiptap/markdown";
import { HomelabApp } from "@/types"; import { HomelabApp } from "@/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Menubar from "./menu-bar"; // Added menubar import back import Menubar from "./menu-bar";
interface TextEditorProps { interface TextEditorProps {
content: string; content: string;
onSave: (content: string, linkedAppIds: string[]) => void; initialTitle?: string;
onSave: (content: string, linkedAppIds: string[], title: string) => void;
initialLinkedAppIds?: string[]; initialLinkedAppIds?: string[];
} }
@@ -22,14 +23,21 @@ const getApps = async (): Promise<HomelabApp[]> => {
export default function TextEditor({ export default function TextEditor({
content, content,
initialTitle = "",
onSave, onSave,
initialLinkedAppIds = [], initialLinkedAppIds = [],
}: TextEditorProps) { }: TextEditorProps) {
const [availableApps, setAvailableApps] = useState<HomelabApp[]>([]); const [availableApps, setAvailableApps] = useState<HomelabApp[]>([]);
const [linkedAppIds, setLinkedAppIds] = const [linkedAppIds, setLinkedAppIds] =
useState<string[]>(initialLinkedAppIds); useState<string[]>(initialLinkedAppIds);
// Title control state
const [title, setTitle] = useState<string>(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(() => { useEffect(() => {
if (initialLinkedAppIds.length > 0) { if (initialLinkedAppIds.length > 0) {
setLinkedAppIds(initialLinkedAppIds); setLinkedAppIds(initialLinkedAppIds);
@@ -44,8 +52,6 @@ export default function TextEditor({
fetchApps(); fetchApps();
}, []); }, []);
// ... rest of your TextEditor component stays the same
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure({ StarterKit.configure({
@@ -57,7 +63,7 @@ export default function TextEditor({
Markdown, Markdown,
], ],
immediatelyRender: false, immediatelyRender: false,
content: content || "<p>Start typing your documentation notes here...</p>", content,
editorProps: { editorProps: {
attributes: { attributes: {
class: "prose max-w-none focus:outline-none min-h-[300px]", class: "prose max-w-none focus:outline-none min-h-[300px]",
@@ -66,32 +72,64 @@ export default function TextEditor({
}); });
const toggleAppLink = (appId: string) => { const toggleAppLink = (appId: string) => {
setLinkedAppIds( setLinkedAppIds((prev) =>
(prev) => prev.includes(appId)
prev.includes(appId) ? prev.filter((id) => id !== appId)
? prev.filter((id) => id !== appId) // Unlink if already added : [...prev, appId],
: [...prev, appId], // Link if not added
); );
}; };
const handleSave = () => { const handleSave = () => {
if (!editor) return; setIsSaved("saving");
const htmlOutput = editor.getHTML(); try {
// Pass both the layout structure and the entire linked app set if (!editor) return;
onSave(htmlOutput, linkedAppIds); 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 ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
{/* Top action bar */} {/* Top action bar updated with live Title Input */}
<div className="flex justify-between items-center pb-2 border-b border-zinc-100"> <div className="flex justify-between items-center pb-2 border-b border-zinc-100 gap-4">
<span className="text-sm text-zinc-400 font-medium">Draft Session</span> <input
<button type="text"
onClick={handleSave} value={title}
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm rounded-md transition-colors" onChange={(e) => setTitle(e.target.value)}
> placeholder="Untitled Document..."
Save Changes className="bg-transparent text-zinc-800 font-semibold text-base placeholder-zinc-300 focus:outline-none flex-1 py-1"
</button> />
<div className="flex items-center gap-2">
{isSaved === "saving" && (
<span className="text-xs text-zinc-400">Saving...</span>
)}
{isSaved === "saved" && (
<span className="text-xs text-emerald-600">Saved</span>
)}
{isSaved === "error" && (
<span className="text-xs text-red-600">Error saving</span>
)}
<button
onClick={handleSave}
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm rounded-md transition-colors shrink-0"
>
Save Changes
</button>
</div>
</div> </div>
{/* Editor Controls */} {/* Editor Controls */}
@@ -129,9 +167,8 @@ export default function TextEditor({
: "bg-white border-zinc-200 hover:border-zinc-300" : "bg-white border-zinc-200 hover:border-zinc-300"
}`} }`}
> >
{/* Subtle active border indicator */}
{isLinked && ( {isLinked && (
<div className="absolute top-0 right-0 h-0 w-0 border-t-[16px] border-t-emerald-500 border-l-[16px] border-l-transparent" /> <div className="absolute top-0 right-0 h-0 w-0 border-t-16 border-t-emerald-500 border-l-16 border-l-transparent" />
)} )}
<div <div
+84
View File
@@ -53,3 +53,87 @@ pub fn delete_app(conn: &mut SqliteConnection, app_id: &str) -> usize {
.execute(conn) .execute(conn)
.expect("Error deleting app") .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::<models::App>(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<String> = 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<String> = 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::<models::Blog>(conn)
.expect("Error loading updated blog")
} else {
// Create new blog
create_blog_db(conn, title, content, linked_app_ids)
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ pub struct App {
pub ssh_address: Option<String>, // default to normal address pub ssh_address: Option<String>, // 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(table_name = crate::db::schema::blog)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Blog { pub struct Blog {
@@ -22,7 +22,7 @@ pub struct Blog {
pub reference: Option<String>, // uuid pub reference: Option<String>, // uuid
} }
#[derive(Queryable, Selectable, Debug, Serialize, Deserialize)] #[derive(Queryable, Selectable, Debug, Insertable, Serialize, Deserialize)]
#[diesel(table_name = crate::db::schema::identities)] #[diesel(table_name = crate::db::schema::identities)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))] #[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Identity { pub struct Identity {
+17 -7
View File
@@ -27,14 +27,12 @@ async fn ping_target(address: &str) -> bool {
let ip_target: IpAddr = socket_addr.ip(); let ip_target: IpAddr = socket_addr.ip();
// 2. Configure unprivileged ICMP requestor
let timeout = Some(Duration::from_secs(2)); let timeout = Some(Duration::from_secs(2));
let pinger = match IcmpEchoRequestor::new(ip_target, None, None, timeout) { let pinger = match IcmpEchoRequestor::new(ip_target, None, None, timeout) {
Ok(p) => p, Ok(p) => p,
Err(_) => return false, Err(_) => return false,
}; };
// 3. Send ICMP Echo request asynchronously
match pinger.send().await { match pinger.send().await {
Ok(reply) => { Ok(reply) => {
// Check if the status returned is a valid success response // Check if the status returned is a valid success response
@@ -46,7 +44,7 @@ async fn ping_target(address: &str) -> bool {
#[tauri::command] #[tauri::command]
fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Option<&str>) -> App { 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); let app = create_app(&mut conn, name, address, description, ssh_address);
println!("{:#?}", app); println!("{:#?}", app);
@@ -55,7 +53,7 @@ fn new_app(name: &str, address: &str, description: Option<&str>, ssh_address: Op
#[tauri::command] #[tauri::command]
fn get_app() -> Vec<App> { fn get_app() -> Vec<App> {
let mut conn = &mut establish_connection(); let mut conn = establish_connection();
let apps = get_apps(&mut conn); let apps = get_apps(&mut conn);
println!("{:#?}", apps); println!("{:#?}", apps);
@@ -64,8 +62,8 @@ fn get_app() -> Vec<App> {
#[tauri::command] #[tauri::command]
fn delete_apps(id: &str) { fn delete_apps(id: &str) {
let conn = &mut establish_connection(); let mut conn = establish_connection();
delete_app(conn, id); delete_app(&mut conn, id);
} }
#[tauri::command] #[tauri::command]
@@ -76,6 +74,17 @@ async fn ping(address: &str) -> Result<bool, String> {
} }
} }
#[tauri::command]
fn save_blog(
id: Option<&str>,
title: &str,
content: &str,
linked_app_ids: Option<Vec<String>>,
) -> 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -84,7 +93,8 @@ pub fn run() {
new_app, new_app,
get_app, get_app,
delete_apps, delete_apps,
ping ping,
save_blog
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");