From b33eaec84cd7d5a294e59edfe8aec0bfd70845b8 Mon Sep 17 00:00:00 2001 From: moddroid94 Date: Sun, 4 Jan 2026 01:46:53 +0100 Subject: [PATCH 1/3] fix(frontend): improvements to layout of sidebar and model list feat(frontend): added thumbnail preview fallback for unsupported 3D files upon adding one feat(frontend): added thumbnail editing feat(api):added thumbnail replacement endpoint --- backend/app.py | 35 +++- frontend/App.tsx | 4 +- frontend/components/DetailPanel.tsx | 233 ++++++++++++++++++------ frontend/components/ModelList.tsx | 8 +- frontend/components/Sidebar.tsx | 32 ++-- frontend/components/Viewer3D.tsx | 30 +-- frontend/services/api.ts | 16 ++ frontend/services/thumbnailGenerator.ts | 72 ++++---- 8 files changed, 307 insertions(+), 123 deletions(-) diff --git a/backend/app.py b/backend/app.py index b4a24ce..0ed3ae4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3,6 +3,7 @@ import time import shutil import sqlite3 +import base64 from fastapi import ( FastAPI, UploadFile, @@ -396,7 +397,7 @@ def replace_model_file( pass filename_str = file.filename or ".stl" - ext = os.path.splitext(filename_str)[1] or ".stl" + ext = os.path.splitext(filename_str)[-1] or ".stl" filename = f"{model_id}{ext}" path = os.path.join(UPLOAD_DIR, filename) size = save_upload_file(file, path) @@ -411,6 +412,38 @@ def replace_model_file( return row_to_model(row) +@app.put("/api/models/{model_id}/thumbnail") +def replace_model_thumbnail( + model_id: str, file: UploadFile = File(...) +): + filename_str = file.filename + ext = os.path.splitext(filename_str)[-1] + if not ext: + raise HTTPException(status_code=429, detail="File not Valid, Extension not found") + + filebytes = file.file.read() + encoded_string = base64.b64encode(filebytes) + baseext = ext[1:] + thumbnail = "data:image/" + baseext + ";base64," + encoded_string.decode() + + 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") + + cur.execute( + "UPDATE models SET thumbnail=? WHERE id=?", + (thumbnail, 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 diff --git a/frontend/App.tsx b/frontend/App.tsx index 6a3823e..a318312 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -987,8 +987,8 @@ const App = () => { {/* File List */}
900 ? "h-[700px]" : "h-[400px]" - }`} + visualViewport.height > 900 ? "h-[700px]" : "h-[400px]" + }`} > {Array.from(folderOptions).map((f) => (
diff --git a/frontend/components/DetailPanel.tsx b/frontend/components/DetailPanel.tsx index 21545b1..611233d 100644 --- a/frontend/components/DetailPanel.tsx +++ b/frontend/components/DetailPanel.tsx @@ -1,11 +1,23 @@ +import React, { useState, useCallback, useRef } from "react"; +import { STLModel } from "../types"; +import Viewer3D from "./Viewer3D"; +import { + X, + Download, + Tag as TagIcon, + Sparkles, + Save, + Edit, + Trash2, + Calendar, + HardDrive, + FileUp, + RefreshCw, + AlertTriangle, +} from "lucide-react"; -import React, { useState, useCallback, useRef } from 'react'; -import { STLModel } from '../types'; -import Viewer3D from './Viewer3D'; -import { X, Download, Tag as TagIcon, Sparkles, Save, Edit, Trash2, Calendar, HardDrive, FileUp, RefreshCw, AlertTriangle } from 'lucide-react'; - -import { generateThumbnail } from '../services/thumbnailGenerator'; -import { api } from '../services/api'; +import { generateThumbnail } from "../services/thumbnailGenerator"; +import { api } from "../services/api"; interface DetailPanelProps { model: STLModel | null; @@ -14,14 +26,21 @@ interface DetailPanelProps { onDelete: (id: string) => void; } -const DetailPanel: React.FC = ({ model, onClose, onUpdate, onDelete }) => { - +const DetailPanel: React.FC = ({ + model, + onClose, + onUpdate, + onDelete, +}) => { const [isReplacing, setIsReplacing] = useState(false); const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(''); - const [editDesc, setEditDesc] = useState(''); - const [editTags, setEditTags] = useState(''); - const [errorState, setErrorState] = useState<{ show: boolean; message: string }>({ show: false, message: '' }); + const [editName, setEditName] = useState(""); + const [editDesc, setEditDesc] = useState(""); + const [editTags, setEditTags] = useState(""); + const [errorState, setErrorState] = useState<{ + show: boolean; + message: string; + }>({ show: false, message: "" }); const fileInputRef = useRef(null); @@ -29,31 +48,32 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD React.useEffect(() => { if (model) { setEditName(model.name); - setEditDesc(model.description || ''); - setEditTags(model.tags.join(', ')); + setEditDesc(model.description || ""); + setEditTags(model.tags.join(", ")); setIsEditing(false); - setErrorState({ show: false, message: '' }); + setErrorState({ show: false, message: "" }); } }, [model]); - const handleModelLoaded = useCallback((dimensions: { x: number; y: number; z: number }) => { - if (model && !model.dimensions) { - onUpdate(model.id, { dimensions }); - } - }, [model, onUpdate]); + const handleModelLoaded = useCallback( + (dimensions: { x: number; y: number; z: number }) => { + if (model && !model.dimensions) { + onUpdate(model.id, { dimensions }); + } + }, + [model, onUpdate] + ); if (!model) return null; - - const handleReplaceFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !model) return; // Extension check const getExtension = (filename: string) => { - const parts = filename.split('.'); - return parts.length > 1 ? parts.pop()?.toLowerCase() : ''; + const parts = filename.split("."); + return parts.length > 1 ? parts.pop()?.toLowerCase() : ""; }; const currentExt = getExtension(model.name); @@ -62,9 +82,9 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD if (currentExt && newExt && currentExt !== newExt) { setErrorState({ show: true, - message: `You cannot replace a .${currentExt} file with a .${newExt} file.` + message: `You cannot replace a .${currentExt} file with a .${newExt} file.`, }); - if (fileInputRef.current) fileInputRef.current.value = ''; + if (fileInputRef.current) fileInputRef.current.value = ""; return; } @@ -82,7 +102,7 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD onUpdate(model.id, { url: updated.url, size: updated.size, - thumbnail: updated.thumbnail + thumbnail: updated.thumbnail, }); // Note: The name and other metadata are preserved unless the user explicitly changes them in the text fields } catch (e) { @@ -90,16 +110,43 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD alert("Failed to replace file"); } finally { setIsReplacing(false); - if (fileInputRef.current) fileInputRef.current.value = ''; + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleReplaceThumbnail = async ( + e: React.ChangeEvent + ) => { + const file = e.target.files?.[0]; + if (!file || !model) return; + + setIsReplacing(true); + try { + const updated = await api.replaceModelThumbnail(model.id, file); + onUpdate(model.id, { + url: updated.url, + size: updated.size, + thumbnail: updated.thumbnail, + }); + // Note: The name and other metadata are preserved unless the user explicitly changes them in the text fields + } catch (e) { + console.error("Failed to replace", e); + alert("Failed to replace file"); + } finally { + setIsReplacing(false); + if (fileInputRef.current) fileInputRef.current.value = ""; } }; const handleSave = () => { - const newTags = editTags.split(',').map(t => t.trim()).filter(t => t.length > 0); + const newTags = editTags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); onUpdate(model.id, { name: editName, description: editDesc, - tags: newTags + tags: newTags, }); setIsEditing(false); }; @@ -109,7 +156,10 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD {/* Header */}

Model Details

-
@@ -117,7 +167,12 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD
{/* Viewer */}
- +
{/* Actions */} @@ -142,9 +197,14 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD {/* Info Form */}
- + {!isEditing && ( - )} @@ -176,14 +236,20 @@ const DetailPanel: React.FC = ({ model, onClose, onUpdate, onD {/* File Replacement Section (Edit Mode Only) */} {isEditing && (
- Source File + + Source File +
= ({ model, onClose, onUpdate, onD onChange={handleReplaceFile} />
-

Replaces geometry but keeps name/desc unless changed.

+

+ Replaces geometry but keeps name/desc unless changed. +

+ + + Thumbnail + +
+
+ thumbnail +
+ + +
+

+ Replaces Thumbnail. +

)}
- Filename + + Filename + {isEditing ? ( = ({ model, onClose, onUpdate, onD
- Description + + Description + {isEditing ? (