diff --git a/README.md b/README.md index 0b69775..5c56216 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![Docker Frontend CI](https://img.shields.io/github/actions/workflow/status/moddroid94/STLVault/Docker%20Backend%20CI.yml?style=for-the-badge&logo=docker&label=Backend)](https://github.com/moddroid94/STLVault/actions/workflows/Docker%20Backend%20CI.yml) [![Docker Pulls](https://img.shields.io/docker/pulls/moddroid94/stlvault-frontend?style=for-the-badge&logo=docker)](https://hub.docker.com/u/moddroid94) + ![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge) **STLVault** is a containerized 3D Model library manager and organizer, designed specifically for 3D printing enthusiasts. It provides a clean, modern web interface to manage your growing collection of STL, STEP, and 3MF files. @@ -25,7 +26,8 @@ - **πŸ”— URL Import:** Import multiple files from Printables URL, with granular file selection. (Only models URL) - **πŸ–±οΈ Drag n' Drop:** Seamlessly import new models or move files between folders. - **πŸ“¦ Bulk Actions:** Tag, move, delete, download, or upload multiple files at once. -- **πŸ‘οΈ 3D Preview:** Integrated web-based 3D viewer for STL and 3MF files. +- **πŸ‘οΈ 3D Preview:** Integrated web-based 3D viewer for STL, 3MF, STEP and STP files, with Trackball/Orbit controls switch to allow full rotational freedom (beta) +- **πŸ–ΌοΈ Custom Thumbnails:** Generate a thumbnail of the model from the 3D viewer directly or upload an image to be shown as a thumbnail. - **🏷️ Metadata Management:** Add tags, descriptions, and metadata to your models for easy retrieval. - **πŸ” Global Search:** Sidebar search and filtering to find models library-wide. @@ -43,10 +45,10 @@ ## πŸ“Έ Screenshots -![Dashboard Preview](https://github.com/user-attachments/assets/3d8aa851-392c-4bd0-8819-2a802ec63e2c) -![Setting Page](https://github.com/user-attachments/assets/f1326a8c-3ef0-4b17-be5a-e75aea2cb59a) -![Upload Modal Preview](https://github.com/user-attachments/assets/34f995d3-bc09-489f-92f3-1408bf0196a0) -![Model Viewer/Info Preview](https://github.com/user-attachments/assets/ac373cf5-3952-4336-8b56-e2864127c3aa) +![Dashboard Preview](https://github.com/user-attachments/assets/33be62e6-d7fd-455b-9ef1-e1d363bff6f8) +![Model Viewer/Info Preview](https://github.com/user-attachments/assets/db0c4141-51f6-408d-a6c5-9b3df20a3fc7)![ModelViewer2](https://github.com/user-attachments/assets/dc470ef9-0cf3-4f08-b60d-3985d2461576) +![Setting Page](https://github.com/user-attachments/assets/23c703ce-73b0-43bb-9ff4-f4a64c5f7147) + --- @@ -138,18 +140,22 @@ The application requires two main volumes to persist data. If you are using the - `/backend/uploads`: Stores your actual 3D model files. - `/backend/data`: Stores the SQLite database file. -> **Tip:** If deploying on a NAS or server, map `/backend/uploads` to your existing 3D model library folder to ingest them (import functionality may be required). - --- ## πŸ—ΊοΈ Roadmap - [x] Basic File Management (Upload, Move, Delete) -- [x] 3D Viewer (STL, 3MF) +- [x] 3D Viewer (STL, 3MF, STEP) - [x] Open in Slicer settings -- [ ] Thumbnails / 3D viewer for STEP -- [x] Model import via Printables URL -- [ ] User Authentication +- [x] Thumbnails / 3D viewer for STEP +- [x] Model import via Printables URL with interactive models selection. +- [ ] Backend folder structure follows frontend +- [ ] "All models" folder Pagination to speedup large collection first load. +- [ ] Zip Import +- [ ] Root folder Scan and import +- [x] Generate thumbnail from 3D Preview (to fix bad oriented models or to choose a better angle) +- [ ] Models Collections (to group models for projects or variants) +- [ ] Multi-User with Authentication --- diff --git a/frontend/App.tsx b/frontend/App.tsx index 0ffe3b6..85474b2 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -20,12 +20,20 @@ import { import JSZip from "jszip"; import { useMediaQuery } from "./hooks/useMediaQuery"; import { useVisualViewport } from "./hooks/useVisualViewport"; +import Snackbar, { SnackbarCloseReason } from "@mui/material/Snackbar"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import Alert from "@mui/material/Alert"; const App = () => { const isDesktop = useMediaQuery("(min-width: 1024px)", true); const isMobile = !isDesktop; const visualViewport = useVisualViewport(); - + const darkTheme = createTheme({ + palette: { + mode: "dark", + }, + }); const [folders, setFolders] = useState([]); const [models, setModels] = useState([]); const [storageStats, setStorageStats] = useState({ @@ -34,6 +42,7 @@ const App = () => { }); const [currentFolderId, setCurrentFolderId] = useState("all"); + const [currentFolderParentId, setCurrentFolderParentId] = useState(""); const [selectedModelId, setSelectedModelId] = useState(null); const [isLoading, setIsLoading] = useState(false); const [uploadQueue, setUploadQueue] = useState(0); @@ -60,7 +69,7 @@ const App = () => { const [modelsOptions, setModelsOptions] = useState([]); const [folderOptions, setFolderOptions] = useState>(new Set()); const [selectedOptions, setSelectedOptions] = useState>( - new Set() + new Set(), ); const [importUrl, setImportUrl] = useState(""); const [importFolderId, setImportFolderId] = useState(""); @@ -78,7 +87,7 @@ const App = () => { setIsLoading(true); try { const [fetchedFolders, fetchedModels, fetchedStats] = await Promise.all( - [api.getFolders(), api.getModels("all"), api.getStorageStats()] + [api.getFolders(), api.getModels("all"), api.getStorageStats()], ); setFolders(fetchedFolders); setModels(fetchedModels); @@ -91,6 +100,7 @@ const App = () => { }; fetchData(); }, []); + const port = localStorage.getItem("api-port-override"); // Refresh storage stats when models change (upload, delete, replace) useEffect(() => { @@ -115,6 +125,11 @@ const App = () => { // Clear selection when changing folders to avoid confusion useEffect(() => { setSelectedIds(new Set()); + setCurrentFolderParentId( + currentFolderId === "all" + ? "all" + : folders.find((f) => f.id === currentFolderId)?.parentId || "all", + ); }, [currentFolderId]); // Close mobile sidebar when switching to desktop @@ -135,7 +150,7 @@ const App = () => { setIsMobileSidebarVisible(false); timeoutId = window.setTimeout( () => setIsMobileSidebarMounted(false), - transitionMs + transitionMs, ); } @@ -171,7 +186,7 @@ const App = () => { const handleCreateFolder = async ( name: string, - parentId: string | null = null + parentId: string | null = null, ) => { try { const newFolder = await api.createFolder(name, parentId); @@ -186,7 +201,7 @@ const App = () => { try { await api.updateFolder(id, newName); setFolders((prev) => - prev.map((f) => (f.id === id ? { ...f, name: newName } : f)) + prev.map((f) => (f.id === id ? { ...f, name: newName } : f)), ); } catch (error) { console.error("Failed to rename folder", error); @@ -199,7 +214,7 @@ const App = () => { if (hasModels || hasSubfolders) { alert( - "Folder must be empty to delete. Please delete or move all models and subfolders first." + "Folder must be empty to delete. Please delete or move all models and subfolders first.", ); return; } @@ -210,29 +225,26 @@ const App = () => { const executeUpload = async ( files: File[], targetFolderId: string, - tags: string[] + tags: string[], ) => { setUploadQueue((prev) => prev + files.length); for (const file of files) { try { let thumbnail: string | undefined = undefined; - const lowerName = file.name.toLowerCase(); - if (lowerName.endsWith(".stl") || lowerName.endsWith(".3mf")) { - try { - thumbnail = await generateThumbnail(file); - } catch (e) { - console.warn( - "Thumbnail generation failed, uploading without thumbnail" - ); - } + try { + thumbnail = await generateThumbnail(file); + } catch (e) { + console.warn( + "Thumbnail generation failed, uploading without thumbnail", + ); } const newModel = await api.uploadModel( file, targetFolderId, thumbnail, - tags + tags, ); setModels((prev) => [newModel, ...prev]); } catch (error) { @@ -245,7 +257,7 @@ const App = () => { const handleUpload = async ( fileList: FileList, - specificFolderId?: string + specificFolderId?: string, ) => { const files = Array.from(fileList); @@ -291,7 +303,7 @@ const App = () => { setModelsOptions([]); // Pre-select current folder if specific, otherwise first available setImportFolderId( - currentFolderId !== "all" ? currentFolderId : folders[0]?.id || "" + currentFolderId !== "all" ? currentFolderId : folders[0]?.id || "", ); setShowImportModal(true); }; @@ -343,7 +355,7 @@ const App = () => { model.parentId, model.previewPath, importFolderId, - model.typeName + model.typeName, ); setUploadQueue((prev) => prev - 1); setModels((prev) => [newModel, ...prev]); @@ -360,7 +372,7 @@ const App = () => { const handleUpdateModel = async (id: string, updates: Partial) => { try { setModels((prev) => - prev.map((m) => (m.id === id ? { ...m, ...updates } : m)) + prev.map((m) => (m.id === id ? { ...m, ...updates } : m)), ); await api.updateModel(id, updates); } catch (error) { @@ -419,11 +431,11 @@ const App = () => { setSelectedIds(newSet); }; - const handleSelectAll = () => { - if (selectedIds.size === filteredModels.length) { + const handleSelectAll = (filtered) => { + if (selectedIds.size === filtered.length) { setSelectedIds(new Set()); } else { - const allIds = filteredModels.map((m) => m.id); + const allIds = filtered.map((m) => m.id); setSelectedIds(new Set(allIds)); } }; @@ -434,8 +446,8 @@ const App = () => { await api.bulkMoveModels(ids, targetFolderId); setModels((prev) => prev.map((m) => - selectedIds.has(m.id) ? { ...m, folderId: targetFolderId } : m - ) + selectedIds.has(m.id) ? { ...m, folderId: targetFolderId } : m, + ), ); setShowMoveModal(false); setSelectedIds(new Set()); @@ -449,8 +461,8 @@ const App = () => { await api.bulkMoveModels(modelIds, targetFolderId); setModels((prev) => prev.map((m) => - modelIds.includes(m.id) ? { ...m, folderId: targetFolderId } : m - ) + modelIds.includes(m.id) ? { ...m, folderId: targetFolderId } : m, + ), ); setSelectedIds(new Set()); } catch (e) { @@ -473,7 +485,7 @@ const App = () => { return { ...m, tags: [...new Set([...m.tags, ...tags])] }; } return m; - }) + }), ); setShowTagModal(false); setSelectedIds(new Set()); @@ -527,716 +539,740 @@ const App = () => { }; return ( -
- {isDesktop ? ( - { - setCurrentFolderId(id); - setSelectedModelId(null); - setShowSettings(false); - }} - onCreateFolder={handleCreateFolder} - onRenameFolder={handleRenameFolder} - onDeleteFolder={handleDeleteFolder} - onMoveToFolder={handleDropMove} - onUploadToFolder={(folderId, files) => handleUpload(files, folderId)} - onOpenSettings={() => setShowSettings(true)} - variant="desktop" - /> - ) : ( - <> - setIsMobileSidebarOpen(true)} + + +
+ {isDesktop ? ( + { + setCurrentFolderId(id); + setSelectedModelId(null); + setShowSettings(false); + }} + onCreateFolder={handleCreateFolder} + onRenameFolder={handleRenameFolder} + onDeleteFolder={handleDeleteFolder} + onMoveToFolder={handleDropMove} + onUploadToFolder={(folderId, files) => + handleUpload(files, folderId) + } onOpenSettings={() => setShowSettings(true)} - showMenuButton={!showSettings} + variant="desktop" /> + ) : ( + <> + setIsMobileSidebarOpen(true)} + onOpenSettings={() => setShowSettings(true)} + showMenuButton={!showSettings} + /> - {isMobileSidebarMounted && ( -
-
setIsMobileSidebarOpen(false)} - /> -
- { - setCurrentFolderId(id); - setSelectedModelId(null); - setShowSettings(false); - setIsMobileSidebarOpen(false); + {isMobileSidebarMounted && ( +
+
setIsMobileSidebarOpen(false)} + /> +
+ { + setCurrentFolderId(id); + setSelectedModelId(null); + setShowSettings(false); + setIsMobileSidebarOpen(false); + }} + onCreateFolder={handleCreateFolder} + onRenameFolder={handleRenameFolder} + onDeleteFolder={handleDeleteFolder} + onMoveToFolder={handleDropMove} + onUploadToFolder={(folderId, files) => + handleUpload(files, folderId) + } + onOpenSettings={() => { + setShowSettings(true); + setIsMobileSidebarOpen(false); + }} + variant="mobile" + /> +
+
+ )} + + )} + + {/* Settings View */} + {showSettings ? ( + setShowSettings(false)} /> + ) : ( + <> +
+ {isLoading ? ( +
+
+
+

+ Processing... +

+
+
+ ) : ( + { + setCurrentFolderId(currentFolderParentId); }} - onCreateFolder={handleCreateFolder} - onRenameFolder={handleRenameFolder} - onDeleteFolder={handleDeleteFolder} + onUpload={(files) => handleUpload(files)} + onImport={handleOpenImport} + onSelectModel={(m) => setSelectedModelId(m.id)} + onDelete={handleDeleteModel} + selectedModelId={selectedModelId} + // Selection Props + selectedIds={selectedIds} + onToggleSelection={handleToggleSelection} + onSelectAll={(filtered) => handleSelectAll(filtered)} + onClearSelection={() => setSelectedIds(new Set())} + onNavigateFolder={(id) => setCurrentFolderId(id)} onMoveToFolder={handleDropMove} onUploadToFolder={(folderId, files) => handleUpload(files, folderId) } - onOpenSettings={() => { - setShowSettings(true); - setIsMobileSidebarOpen(false); - }} - variant="mobile" /> -
-
- )} - - )} - - {/* Settings View */} - {showSettings ? ( - setShowSettings(false)} /> - ) : ( - <> -
- {isLoading ? ( -
-
-
-

Processing...

+ )} + + {/* Upload Indicator */} + {uploadQueue > 0 && ( +
+
+ + Uploading {uploadQueue} file(s)... +
-
- ) : ( - handleUpload(files)} - onImport={handleOpenImport} - onSelectModel={(m) => setSelectedModelId(m.id)} - onDelete={handleDeleteModel} - selectedModelId={selectedModelId} - // Selection Props - selectedIds={selectedIds} - onToggleSelection={handleToggleSelection} - onSelectAll={handleSelectAll} - onClearSelection={() => setSelectedIds(new Set())} - onNavigateFolder={(id) => setCurrentFolderId(id)} - onMoveToFolder={handleDropMove} - onUploadToFolder={(folderId, files) => - handleUpload(files, folderId) - } - /> - )} + )} - {/* Upload Indicator */} - {uploadQueue > 0 && ( -
-
- - Uploading {uploadQueue} file(s)... - -
- )} - - {/* Backdrop for closing sidebar */} -
setSelectedModelId(null)} - /> - - {/* Slide-over panel */} -
- setSelectedModelId(null)} - onUpdate={handleUpdateModel} - onDelete={handleDeleteModel} + {/* Backdrop for closing sidebar */} +
setSelectedModelId(null)} /> -
- - {/* Floating Action Bar - Moved to App to ensure it is top-level Z-index */} - {selectedIds.size > 0 && ( -
-
- - {selectedIds.size} - - selected - -
-
- + {/* Slide-over panel */} +
+ setSelectedModelId(null)} + onUpdate={handleUpdateModel} + onDelete={handleDeleteModel} + /> +
- + selected + +
- +
+ - -
-
- )} + - {/* Modals Layer */} + - {/* Upload Modal */} - {showUploadModal && ( -
-
-
-

- Upload Files -

+
+ )} -
-
-

- {pendingFiles.length} files selected -

-

- {pendingFiles.map((f) => f.name).join(", ")} -

-
- -
- - -
+ {/* Modals Layer */} -
- - setUploadTags(e.target.value)} - /> -

- Separate tags with commas -

-
- -
+ {/* Upload Modal */} + {showUploadModal && ( +
+
+
+

+ Upload + Files +

-
- + +
+
+

+ {pendingFiles.length} files selected +

+

+ {pendingFiles.map((f) => f.name).join(", ")} +

+
+ +
+ + +
+ +
+ + setUploadTags(e.target.value)} + /> +

+ Separate tags with commas +

+
+ +
+ + +
+
+
-
- )} + )} - {/* Import URL Modal */} - {showImportModal && ( -
+ {/* Import URL Modal */} + {showImportModal && (
-
-

- Import from - URL -

- -
- -
-
- - setImportUrl(e.target.value)} - /> -

- Paste a link from Printables or similar sites -

-
- -
- - -
- -
+
+
+

+ Import + from URL +

-
- + +
+
+ + setImportUrl(e.target.value)} + /> +

+ Paste a link from Printables or similar sites +

+
+ +
+ + +
+ +
+ + +
+
+
-
- )} + )} - {/* Import Options Modal */} - {showImportOptionsModal && ( -
+ {/* Import Options Modal */} + {showImportOptionsModal && (
-
-

- Select model - to download -

- -
- - {/* File List */}
900 ? "h-[700px]" : "h-[400px]" - }`} + className="relative bg-vault-800 border border-vault-600 rounded-xl p-6 w-full lg:w-1/2 shadow-2xl animate-in zoom-in-95 duration-200 " + style={{ + maxHeight: Math.max( + 240, + (visualViewport.height || + (typeof window !== "undefined" + ? window.innerHeight + : 0)) - 32, + ), + }} > - {Array.from(folderOptions).map((f) => ( -
-
- {f ? f : "Root Folder"} -
- {modelsOptions.map((model) => ( -
- {model.folder == f ? ( -
- handleOptionsToggleSelection(model.id) - } - className={`group bg-vault-900 border rounded-xl p-4 cursor-pointer transition-all flex items-center gap-4 mb-2 relative overflow-hidden +
+

+ Select + model to download +

+ +
+ + {/* File List */} +
900 ? "h-[700px]" : "h-[400px]" + }`} + > + {Array.from(folderOptions).map((f) => ( +
+
+ {f ? f : "Root Folder"} +
+ {modelsOptions.map((model) => ( +
+ {model.folder == f ? ( +
+ handleOptionsToggleSelection(model.id) + } + className={`group bg-vault-900 border rounded-xl p-4 cursor-pointer transition-all flex items-center gap-4 mb-2 relative overflow-hidden ${ selectedOptions.has(model.id) ? "border-blue-500 ring-1 ring-blue-500/50" : "border-vault-700 hover:border-vault-600" } `} - > -
- {model.name} + > +
+ {model.name} +
+ +
+

+ {model.name} +

+

+ {model.typeName} +

+
+ ) : ( +
+ )} +
+ ))} +
+ ))} +
-
-

- {model.name} -

-

- {model.typeName} -

-
-
- ) : ( -
- )} -
- ))} -
- ))} -
- -
handleImportChoice()} - className="static bottom-0 p-2 mt-4 cursor-pointer rounded-lg bg-vault-700 hover:bg-vault-600 text-slate-200 font-medium transition-colors text-center" - > - {" "} - Import{" "} +
handleImportChoice()} + className="static bottom-0 p-2 mt-4 cursor-pointer rounded-lg bg-vault-700 hover:bg-vault-600 text-slate-200 font-medium transition-colors text-center" + > + {" "} + Import{" "} +
-
- )} + )} - {/* Delete Confirmation Modal */} - {deleteConfirmState.isOpen && ( -
+ {/* Delete Confirmation Modal */} + {deleteConfirmState.isOpen && (
-
-
- +
+
+
+ +
+

+ Confirm Deletion +

+

+ {deleteConfirmState.type === "single" && + "Are you sure you want to delete this model? This action cannot be undone."} + {deleteConfirmState.type === "bulk" && + `Are you sure you want to delete ${selectedIds.size} models? This action cannot be undone.`} + {deleteConfirmState.type === "folder" && + "Are you sure you want to delete this folder?"} +

-

- Confirm Deletion -

-

- {deleteConfirmState.type === "single" && - "Are you sure you want to delete this model? This action cannot be undone."} - {deleteConfirmState.type === "bulk" && - `Are you sure you want to delete ${selectedIds.size} models? This action cannot be undone.`} - {deleteConfirmState.type === "folder" && - "Are you sure you want to delete this folder?"} -

-
-
- - +
+ + +
-
- )} + )} - {showMoveModal && ( -
+ {showMoveModal && (
-
-

- Move to Folder -

- -
-
- {folders.map((folder) => ( +
+
+

+ Move to Folder +

- ))} +
+
+ {folders.map((folder) => ( + + ))} +
-
- )} + )} - {showTagModal && ( -
+ {showTagModal && (
-
-

- Add Tags -

- -
-
-

- Add tags to {selectedIds.size} items (comma separated): -

- setBulkTags(e.target.value)} - /> -
+
+
+

+ Add Tags +

-
- +
+

+ Add tags to {selectedIds.size} items (comma separated): +

+ setBulkTags(e.target.value)} + /> +
+ + +
+
+
-
- )} -
- - )} -
+ )} + + + )} + + + API Host Not Set + + +
+ ); }; diff --git a/frontend/assets/globals.css b/frontend/assets/globals.css new file mode 100644 index 0000000..dbbf6fb --- /dev/null +++ b/frontend/assets/globals.css @@ -0,0 +1,26 @@ +/* global.css */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* width */ +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #000000; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 5px; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/frontend/components/DetailPanel.tsx b/frontend/components/DetailPanel.tsx index 611233d..2c79aaf 100644 --- a/frontend/components/DetailPanel.tsx +++ b/frontend/components/DetailPanel.tsx @@ -14,10 +14,20 @@ import { FileUp, RefreshCw, AlertTriangle, + ScreenShareIcon, } from "lucide-react"; import { generateThumbnail } from "../services/thumbnailGenerator"; import { api } from "../services/api"; +import { Typography } from "@mui/material"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import Divider from "@mui/material/Divider"; +import OutlinedInput from "@mui/material/OutlinedInput"; +import TextField from "@mui/material/TextField"; +import Badge from "@mui/material/Badge"; +import Chip from "@mui/material/Chip"; +import Grid from "@mui/material/Grid"; interface DetailPanelProps { model: STLModel | null; @@ -37,6 +47,7 @@ const DetailPanel: React.FC = ({ const [editName, setEditName] = useState(""); const [editDesc, setEditDesc] = useState(""); const [editTags, setEditTags] = useState(""); + const [tempThumb, setTempThumb] = useState(""); const [errorState, setErrorState] = useState<{ show: boolean; message: string; @@ -51,6 +62,8 @@ const DetailPanel: React.FC = ({ setEditDesc(model.description || ""); setEditTags(model.tags.join(", ")); setIsEditing(false); + setIsReplacing(false); + setTempThumb(""); setErrorState({ show: false, message: "" }); } }, [model]); @@ -61,7 +74,7 @@ const DetailPanel: React.FC = ({ onUpdate(model.id, { dimensions }); } }, - [model, onUpdate] + [model, onUpdate], ); if (!model) return null; @@ -115,7 +128,7 @@ const DetailPanel: React.FC = ({ }; const handleReplaceThumbnail = async ( - e: React.ChangeEvent + e: React.ChangeEvent, ) => { const file = e.target.files?.[0]; if (!file || !model) return; @@ -138,263 +151,379 @@ const DetailPanel: React.FC = ({ } }; + const handleGenerateThumbnail = (dataurl: string) => { + setTempThumb(dataurl); + }; + const handleSave = () => { + const getExtension = (filename: string) => { + const parts = filename.split("."); + return parts.length > 1 ? parts.pop()?.toLowerCase() : ""; + }; + + const currentExt = getExtension(model.name); + const editExt = getExtension(editName); + let newName = ""; + if (editExt != currentExt) { + newName = editName + "." + currentExt; + } else { + newName = editName; + } + const newTags = editTags .split(",") .map((t) => t.trim()) .filter((t) => t.length > 0); - onUpdate(model.id, { - name: editName, - description: editDesc, - tags: newTags, - }); + + if (tempThumb != "") { + onUpdate(model.id, { + name: newName, + description: editDesc, + tags: newTags, + thumbnail: tempThumb, + }); + } else { + onUpdate(model.id, { + name: newName, + description: editDesc, + tags: newTags, + }); + } + setIsEditing(false); }; return ( -
+
{/* Header */} +
-

Model Details

- + Model Details +
{/* Viewer */} -
+
{/* Actions */} - + + Open in Slicer + + + {/* Info Form */}
-
- - {!isEditing && ( - +
+ + Name + + {isEditing ? ( + setEditName(e.target.value)} + /> + ) : ( + + {model.name} + + )} +
+ + + Filename:

+ {model.id}.{model.name.split(".").pop()} +
+ +
+ + Description + + + {isEditing ? ( + setEditDesc(e.target.value)} + placeholder="Add a description..." + multiline + /> + ) : ( + + {model.description || "No Description"} + )}
+ + + Metadata
{/* Quick Stats Grid */} -
+
+
+ + + Tags: + + {isEditing ? ( + setEditTags(e.target.value)} + placeholder="scifi, armor, character..." + multiline + /> + ) : ( + + {model.tags.length > 0 ? ( + model.tags.map((tag) => ( + + } + > + + )) + ) : ( + + No tags + + )} + + )} + +
+ +
-
+ - Added -
-

- {new Date(model.dateAdded).toLocaleDateString()} -

+ + Added: + + + {new Date(model.dateAdded).toLocaleDateString()} + +
-
+ - File Size -
-

- {(model.size / (1024 * 1024)).toFixed(2)} MB -

+ + File Size: + + + {(model.size / (1024 * 1024)).toFixed(2)} MB + +
+ {/* File Replacement Section (Edit Mode Only) */} {isEditing && (
- - Source File - -
- - + +
-

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

- - - Thumbnail - -
-
+ + + Thumbnail: + + +
thumbnail
- - -
-

- Replaces Thumbnail. -

+ + + +
)} -
- - Filename - - {isEditing ? ( - setEditName(e.target.value)} - /> - ) : ( -

{model.name}

- )} -
- -
- - Description - - {isEditing ? ( -