Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 9 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@

**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.
> **Note:** This project is still in Beta. While the core functionality (importing, organizing, viewing) works, expect changes and improvements.

---

## ✨ Features

- **📖 Manuals:** Include markdown manuals for every model, with github style compatibility.
- **📂 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)
Expand Down Expand Up @@ -54,6 +55,10 @@

The recommended way to deploy STLVault is using **Docker Compose** or via a container management tool like **Portainer**.

Replace the variables or create a .env file to let docker handle the injection.

`An example .env file is in the section below.`

## Docker Compose with Images

```
Expand All @@ -63,13 +68,15 @@ services:
pull_policy: build
environment:
- FILE_STORAGE=/app/uploads #DO NOT CHANGE, MODIFY THE BINDS
- MANUAL_STORAGE=/app/uploads/manuals #DO NOT CHANGE, MODIFY THE BINDS
- DB_PATH=/app/data/data.db #DO NOT CHANGE, MODIFY THE BINDS
- WEBUI_URL: "${APP_URL}"
ports:
- '8998:8080'
volumes:
- YOUR_FOLDER_PATH:/app/uploads
- YOUR_FOLDER_PATH:/app/data
- YOUR_FOLDER_PATH:/app/manuals #OPTIONAL
restart: always
stlvfrontend:
image: moddroid94/stlvault-frontend:latest
Expand Down Expand Up @@ -143,31 +150,4 @@ The application requires two main volumes to persist data. If you are using the

- [x] Basic File Management (Upload, Move, Delete)
- [x] 3D Viewer (STL, 3MF, STEP)
- [x] Open in Slicer settings
- [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

---

## 🤝 Contributing

Contributions are welcome! Since this project uses a standard React + FastAPI stack, it is easy to set up for development.

1. Fork the repository.
2. Create a feature branch (`git checkout -b feature/AmazingFeature`).
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
4. Push to the branch (`git push origin feature/AmazingFeature`).
5. Open a Pull Request.

---

## 📝 License

[MIT License](LICENSE)
- [ ] Storage Template for saving files orderly
80 changes: 77 additions & 3 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -455,11 +475,65 @@ 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():
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}

Expand Down
51 changes: 51 additions & 0 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
/>
</div>

Expand Down Expand Up @@ -785,6 +829,13 @@ const App = () => {

{/* Modals Layer */}

<ManualModal
model={manualModel}
initialMode={manualState.mode}
onClose={() => setManualState({ id: null, mode: "view" })}
onSave={handleUploadManual}
/>

{/* Upload Modal */}
{showUploadModal && (
<div
Expand Down
Loading
Loading