From a7bafc4fe89550bc5cc69d9e4c0692c29c0ba4eb Mon Sep 17 00:00:00 2001 From: Juraj Hrib Date: Thu, 11 Jun 2026 17:55:00 +0200 Subject: [PATCH 1/6] feat: Support for MD manual for each model --- backend/app.py | 89 ++++++++++- frontend/App.tsx | 51 +++++++ frontend/components/DetailPanel.tsx | 112 ++++++++++++++ frontend/components/ManualModal.tsx | 211 ++++++++++++++++++++++++++ frontend/components/ModelList.tsx | 16 ++ frontend/components/styles/manual.css | 82 ++++++++++ frontend/package.json | 2 + frontend/services/api.ts | 28 +++- frontend/types.ts | 1 + 9 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 frontend/components/ManualModal.tsx create mode 100644 frontend/components/styles/manual.css diff --git a/backend/app.py b/backend/app.py index 24ee3e3..786d111 100644 --- a/backend/app.py +++ b/backend/app.py @@ -23,6 +23,8 @@ DB_PATH = os.getenv("DB_PATH", "data.db") UPLOAD_DIR = Path(os.getenv("FILE_STORAGE", "./app/uploads")) +MANUAL_DIR = Path(os.getenv("MANUAL_STORAGE", UPLOAD_DIR / "manuals")) +MANUAL_DIR.mkdir(parents=True, exist_ok=True) WEBUI_URL = os.getenv("WEBUI_URL", "http://localhost:8989") @@ -70,10 +72,15 @@ def init_db(): dateAdded INTEGER, tags TEXT, description TEXT, - thumbnail TEXT + thumbnail TEXT, + manual TEXT ) """ ) + try: + cur.execute("ALTER TABLE models ADD COLUMN manual TEXT") + except sqlite3.OperationalError: + pass conn.commit() # seed folders if empty @@ -119,6 +126,7 @@ def row_to_model(row: sqlite3.Row) -> Dict[str, Any]: "tags": tags, "description": row["description"] or "", "thumbnail": row["thumbnail"], + "manual": row["manual"] if "manual" in row.keys() else None, } @@ -314,6 +322,12 @@ def delete_model(model_id: str): os.remove(os.path.join(UPLOAD_DIR, fname)) except Exception: pass + manual_path = MANUAL_DIR / f"{model_id}.md" + if manual_path.exists(): + try: + manual_path.unlink() + except Exception: + pass cur.execute("DELETE FROM models WHERE id=?", (model_id,)) conn.commit() conn.close() @@ -347,6 +361,12 @@ def bulk_delete(payload: dict): os.remove(os.path.join(UPLOAD_DIR, fname)) except Exception: pass + manual_path = MANUAL_DIR / f"{mid}.md" + if manual_path.exists(): + try: + manual_path.unlink() + except Exception: + pass cur.execute("DELETE FROM models WHERE id=?", (mid,)) conn.commit() conn.close() @@ -455,11 +475,74 @@ def replace_model_thumbnail( return row_to_model(row) +@app.get("/api/models/{model_id}/manual") +def get_model_manual(model_id: str): + path = MANUAL_DIR / f"{model_id}.md" + if not path.exists(): + conn = get_db_conn() + cur = conn.cursor() + cur.execute( + "UPDATE models SET manual=NULL WHERE id=? AND manual IS NOT NULL", + (model_id,), + ) + if cur.rowcount > 0: + conn.commit() + conn.close() + raise HTTPException(status_code=404, detail="Manual not found") + return FileResponse(path, media_type="text/markdown") + + +@app.put("/api/models/{model_id}/manual") +def upload_model_manual(model_id: str, file: UploadFile = File(...)): + conn = get_db_conn() + cur = conn.cursor() + m = cur.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone() + if not m: + conn.close() + raise HTTPException(status_code=404, detail="Model not found") + + path = MANUAL_DIR / f"{model_id}.md" + save_upload_file(file, str(path)) + + cur.execute( + "UPDATE models SET manual=? WHERE id=?", + (file.filename, model_id), + ) + conn.commit() + row = cur.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone() + conn.close() + return row_to_model(row) + + +@app.delete("/api/models/{model_id}/manual") +def delete_model_manual(model_id: str): + conn = get_db_conn() + cur = conn.cursor() + m = cur.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone() + if not m: + conn.close() + raise HTTPException(status_code=404, detail="Model not found") + + path = MANUAL_DIR / f"{model_id}.md" + if path.exists(): + try: + path.unlink() + except Exception: + pass + + cur.execute("UPDATE models SET manual=NULL WHERE id=?", (model_id,)) + conn.commit() + row = cur.execute("SELECT * FROM models WHERE id=?", (model_id,)).fetchone() + conn.close() + return row_to_model(row) + + @app.get("/api/storage-stats") def storage_stats(): used = 0 - for fname in os.listdir(UPLOAD_DIR): - used += os.path.getsize(os.path.join(UPLOAD_DIR, fname)) + for root, _dirs, files in os.walk(UPLOAD_DIR): + for fname in files: + used += os.path.getsize(os.path.join(root, fname)) total = 5 * 1024 * 1024 * 1024 return {"used": used, "total": total} diff --git a/frontend/App.tsx b/frontend/App.tsx index 5446cb1..bb62d6e 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -4,6 +4,7 @@ import ModelList from "./components/ModelList"; import DetailPanel from "./components/DetailPanel"; import Settings from "./components/Settings"; import Navbar from "./components/Navbar"; +import ManualModal from "./components/ManualModal"; import { STLModel, Folder, StorageStats, STLModelCollection } from "./types"; import { generateThumbnail } from "./services/thumbnailGenerator"; import { api } from "./services/api"; @@ -81,6 +82,11 @@ const App = () => { id?: string; }>({ isOpen: false, type: "single" }); + const [manualState, setManualState] = useState<{ + id: string | null; + mode: "view" | "edit"; + }>({ id: null, mode: "view" }); + // Initial Data Fetch useEffect(() => { const fetchData = async () => { @@ -177,6 +183,7 @@ const App = () => { }, [isMobileSidebarMounted]); const selectedModel = models.find((m) => m.id === selectedModelId) || null; + const manualModel = models.find((m) => m.id === manualState.id) || null; const currentFolderName = currentFolderId === "all" @@ -399,6 +406,32 @@ const App = () => { } }; + const handleUploadManual = async (id: string, file: File) => { + try { + const updated = await api.uploadManual(id, file); + setModels((prev) => + prev.map((m) => (m.id === id ? { ...m, manual: updated.manual } : m)), + ); + return updated; + } catch (error) { + console.error("Failed to upload manual:", error); + alert("Failed to upload manual"); + throw error; + } + }; + + const handleDeleteManual = async (id: string) => { + try { + const updated = await api.deleteManual(id); + setModels((prev) => + prev.map((m) => (m.id === id ? { ...m, manual: updated.manual } : m)), + ); + } catch (error) { + console.error("Failed to delete manual:", error); + alert("Failed to delete manual"); + } + }; + const handleDeleteModel = (id: string) => { console.log("Opening delete confirmation for model:", id); setDeleteConfirmState({ isOpen: true, type: "single", id }); @@ -668,6 +701,9 @@ const App = () => { onImport={handleOpenImport} onSelectModel={(m) => setSelectedModelId(m.id)} onDelete={handleDeleteModel} + onOpenManual={(m) => + setManualState({ id: m.id, mode: "view" }) + } selectedModelId={selectedModelId} // Selection Props selectedIds={selectedIds} @@ -713,6 +749,14 @@ const App = () => { onClose={() => setSelectedModelId(null)} onUpdate={handleUpdateModel} onDelete={handleDeleteModel} + onOpenManual={(m) => + setManualState({ id: m.id, mode: "view" }) + } + onEditManual={(m) => + setManualState({ id: m.id, mode: "edit" }) + } + onUploadManual={handleUploadManual} + onDeleteManual={handleDeleteManual} /> @@ -785,6 +829,13 @@ const App = () => { {/* Modals Layer */} + setManualState({ id: null, mode: "view" })} + onSave={handleUploadManual} + /> + {/* Upload Modal */} {showUploadModal && (
void; onUpdate: (id: string, updates: Partial) => void; onDelete: (id: string) => void; + onOpenManual: (model: STLModel) => void; + onEditManual: (model: STLModel) => void; + onUploadManual: (id: string, file: File) => void | Promise; + onDeleteManual: (id: string) => void | Promise; } const DetailPanel: React.FC = ({ @@ -41,6 +48,10 @@ const DetailPanel: React.FC = ({ onClose, onUpdate, onDelete, + onOpenManual, + onEditManual, + onUploadManual, + onDeleteManual, }) => { const [isReplacing, setIsReplacing] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -54,6 +65,7 @@ const DetailPanel: React.FC = ({ }>({ show: false, message: "" }); const fileInputRef = useRef(null); + const manualInputRef = useRef(null); // Reset local state when model changes React.useEffect(() => { @@ -155,6 +167,18 @@ const DetailPanel: React.FC = ({ setTempThumb(dataurl); }; + const handleManualUpload = async ( + e: React.ChangeEvent, + ) => { + const file = e.target.files?.[0]; + if (!file || !model) return; + try { + await onUploadManual(model.id, file); + } finally { + if (manualInputRef.current) manualInputRef.current.value = ""; + } + }; + const handleSave = () => { const getExtension = (filename: string) => { const parts = filename.split("."); @@ -294,6 +318,94 @@ const DetailPanel: React.FC = ({
+
+ + Manual + + + {isEditing ? ( + model.manual ? ( + + + {model.manual} + + + onEditManual(model)} + aria-label="edit manual" + > + + + + + onDeleteManual(model.id)} + aria-label="delete manual" + > + + + + + ) : ( + + + + + ) + ) : model.manual ? ( + + ) : ( + + No manual + + )} +
+ + Metadata
diff --git a/frontend/components/ManualModal.tsx b/frontend/components/ManualModal.tsx new file mode 100644 index 0000000..9980b66 --- /dev/null +++ b/frontend/components/ManualModal.tsx @@ -0,0 +1,211 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { X, Edit, Save } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import Tooltip from "@mui/material/Tooltip"; + +import { STLModel } from "../types"; +import { api } from "../services/api"; +import { useVisualViewport } from "../hooks/useVisualViewport"; +import "./styles/manual.css"; + +type ManualMode = "view" | "edit"; + +interface ManualModalProps { + model: STLModel | null; + onClose: () => void; + initialMode?: ManualMode; + onSave: (id: string, file: File) => Promise; +} + +const ManualModal: React.FC = ({ + model, + onClose, + initialMode = "view", + onSave, +}) => { + const visualViewport = useVisualViewport(); + const [mode, setMode] = useState(initialMode); + const [content, setContent] = useState(""); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const textareaRef = useRef(null); + + const autoResizeTextarea = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, []); + + useEffect(() => { + if (mode === "edit") autoResizeTextarea(); + }, [mode, draft, autoResizeTextarea]); + + useEffect(() => { + if (!model) return; + setMode(initialMode); + setError(""); + setContent(""); + setDraft(""); + + if (!model.manual) { + return; + } + + let cancelled = false; + setLoading(true); + fetch(api.getManualUrl(model)) + .then(async (res) => { + if (!res.ok) throw new Error("Failed to load manual"); + return res.text(); + }) + .then((text) => { + if (cancelled) return; + setContent(text); + setDraft(text); + }) + .catch((err) => { + if (!cancelled) setError(err.message || "Failed to load manual"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [model?.id]); + + useEffect(() => { + if (!model) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [model, onClose]); + + if (!model) return null; + + const viewportHeight = + visualViewport.height || + (typeof window !== "undefined" ? window.innerHeight : 0); + + const handleSave = async () => { + if (!model || saving) return; + setSaving(true); + setError(""); + try { + const filename = model.manual || "manual.md"; + const file = new File([draft], filename, { type: "text/markdown" }); + await onSave(model.id, file); + setContent(draft); + setMode("view"); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to save manual", + ); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + if (!model.manual) { + onClose(); + return; + } + setDraft(content); + setMode("view"); + }; + + return ( +
+
e.stopPropagation()} + > +
+
+

+ {model.name} +

+ {mode === "view" ? ( + + + + ) : ( +
+ + +
+ )} +
+ +
+ +
+ {loading && ( +

Loading manual...

+ )} + {!loading && error && ( +

{error}

+ )} + {!loading && mode === "view" && !error && ( +
+ {content} +
+ )} + {!loading && mode === "edit" && ( +