diff --git a/README.md b/README.md index af030d9..0b69775 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ![logo](https://github.com/moddroid94/STLVault/blob/main/frontend/assets/android-chrome-192x192.png) -# STLVault + +# STLVault ![Project Status](https://img.shields.io/badge/Status-Beta-orange?style=for-the-badge) ![GitHub Release](https://img.shields.io/github/v/release/moddroid94/STLVault?display_name=release&style=for-the-badge&logo=github) @@ -11,8 +12,6 @@ ![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. > **Note:** This project is currently in Beta. While the core functionality (importing, organizing, viewing) works, expect changes and improvements. @@ -23,7 +22,7 @@ - **📂 Nestable Folders:** Organize your models into a deep hierarchy that makes sense to you. - **🪄 Open in Slicer:** Let's you open the model direclty in your slicer. -- **🔗 URL Import:** Import multiple files from Printables URL, with granular file selection. (Only models URL, not collections) +- **🔗 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. @@ -38,7 +37,7 @@ - **Backend:** Python (FastAPI) - **Database:** SQLite - **Package Manager:** NPM, UV -- **Containerization:** Docker & Docker Compose +- **Containerization:** Docker & Docker Compose --- @@ -49,17 +48,44 @@ ![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) - - --- ## 🚀 Deployment The recommended way to deploy STLVault is using **Docker Compose** or via a container management tool like **Portainer**. -**Images are available on docker-hub, just replace the build steps with the image tag in the docker-compose. (you will need to set the API Host in the app settings)** - -### Option 1: Docker Compose (CLI) +## Docker Compose with Images + +``` +services: + stlvbackend: + image: moddroid94/stlvault-backend:latest + pull_policy: build + environment: + - FILE_STORAGE=/app/uploads + - DB_PATH=/app/data/data.db + - WEBUI_URL=http://192.168.178.21:8999 + ports: + - '8998:8080' + volumes: + - YOUR_FOLDER_PATH:/app/uploads + - YOUR_FOLDER_PATH:/app/data + restart: always + stlvfrontend: + image: moddroid94/stlvault-frontend:latest + pull_policy: build + volumes: + - node_modules:/app/node_modules + ports: + - '8999:5173' + depends_on: + - stlvbackend + restart: always +volumes: + node_modules: null +``` + +### Docker Compose (CLI) 1. **Clone the repository:** @@ -88,7 +114,7 @@ The recommended way to deploy STLVault is using **Docker Compose** or via a cont 4. **Access the App:** Open your browser and navigate to `http://localhost:8989` (or the port you configured). -### Option 2: Portainer or GitOps (Recommended) +### Portainer You can deploy STLVault directly from Portainer using the repository as a stack source. 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..0ffe3b6 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -342,7 +342,8 @@ const App = () => { model.name, model.parentId, model.previewPath, - importFolderId + importFolderId, + model.typeName ); setUploadQueue((prev) => prev - 1); setModels((prev) => [newModel, ...prev]); @@ -987,8 +988,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 ? (