Compare commits

...

2 Commits

7 changed files with 264 additions and 59 deletions

View File

@@ -16,7 +16,7 @@ COPY . .
RUN bun run build RUN bun run build
# Production stage # Production stage
FROM oven/bun:1-slim AS production FROM oven/bun:1 AS production
WORKDIR /app WORKDIR /app
@@ -43,16 +43,5 @@ ENV PORT=3000
# Expose the port # Expose the port
EXPOSE 3000 EXPOSE 3000
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 filaprint && \
chown -R filaprint:nodejs /app
USER filaprint
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
# Start the application # Start the application
CMD ["bun", "run", "start"] CMD ["bun", "run", "start"]

144
README.md
View File

@@ -1,6 +1,8 @@
# Filaprint # Filaprint
Filaprint is a modern, premium web application designed to help 3D printing enthusiasts manage their filament inventory, track print jobs, and calculate costs and energy usage. Filaprint is a modern, premium web application designed to help 3D printing enthusiasts manage their filament inventory, track print jobs, view 3D models, and calculate costs and energy usage.
![Filaprint Dashboard](https://img.shields.io/badge/Filaprint-3D%20Print%20Manager-blue?style=for-the-badge)
## 🛠️ Technology Stack ## 🛠️ Technology Stack
@@ -9,10 +11,12 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- **Styling:** Tailwind CSS v4 (Cerberus Theme) - **Styling:** Tailwind CSS v4 (Cerberus Theme)
- **State Management:** Svelte 5 Runes - **State Management:** Svelte 5 Runes
- **Build Tool:** Vite - **Build Tool:** Vite
- **3D Rendering:** Three.js (STL & OBJ loaders)
- **Data Visualization:** Chart.js - **Data Visualization:** Chart.js
- **Icons:** Iconify (@iconify/svelte) - **Icons:** Iconify (@iconify/svelte)
- **Database:** MongoDB with Mongoose - **Database:** MongoDB with Mongoose
- **Authentication:** JWT with bcrypt password hashing - **Authentication:** JWT with bcrypt password hashing
- **Container:** Docker with Docker Compose
## ✨ Features ## ✨ Features
@@ -37,23 +41,39 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- **Log Prints:** - **Log Prints:**
- Link to specific Printer and Filament Spool. - Link to specific Printer and Filament Spool.
- Duration (minutes) and Weight used (g). - Duration input with hours and minutes fields.
- Calculated Cost (auto-calculated or manual override). - Weight used (g) and calculated cost (auto or manual).
- Status: Success, Fail, Cancelled, **In Progress**. - Status: Success, Fail, Cancelled, **In Progress**.
- **3D Model Upload:** Attach STL or OBJ files to prints.
- **In Progress Tracking:** - **In Progress Tracking:**
- Assign printer and spool to active jobs. - Assign printer and spool to active jobs.
- Specify elapsed time for accurate dashboard countdown. - Specify elapsed time for accurate dashboard countdown.
- Real-time progress display on dashboard. - Real-time progress display on dashboard.
- **Cost Calculation:**
- Filament cost based on spool price and weight used.
- Electricity cost based on printer power consumption and duration.
- User-configurable electricity rate ($/kWh).
- **Edit/Delete:** Full CRUD operations for print history. - **Edit/Delete:** Full CRUD operations for print history.
- **History:** Clickable entries with detailed information. - **History:** Clickable entries with detailed information.
### 4. Printer Configuration ### 4. 3D Model Library
- **Model Gallery:** Browse all uploaded 3D models in a grid layout.
- **Interactive 3D Viewer:**
- Support for STL and OBJ file formats.
- Orbit controls (rotate, pan, zoom).
- Touch support for mobile devices.
- Auto-rotation with stop on interaction.
- **Upload Progress:** Progress bar with percentage for model uploads.
- **Full-Screen View:** Click to view models in an immersive full-screen viewer.
### 5. Printer Configuration
- **Profiles:** Manage multiple printers with custom names. - **Profiles:** Manage multiple printers with custom names.
- **Specs:** Model name, Power consumption (Watts), Nozzle diameter (mm). - **Specs:** Model name, Power consumption (Watts), Nozzle diameter (mm).
- **Configure Button:** Edit or delete printer profiles. - **Configure Button:** Edit or delete printer profiles.
### 5. Analytics ### 6. Analytics
- **Daily Filament Usage:** Line chart showing filament consumption over time. - **Daily Filament Usage:** Line chart showing filament consumption over time.
- **Daily Electricity Usage:** Bar chart showing power consumption in kWh. - **Daily Electricity Usage:** Bar chart showing power consumption in kWh.
@@ -61,10 +81,13 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- **Material Distribution:** Doughnut chart showing material breakdown. - **Material Distribution:** Doughnut chart showing material breakdown.
- **Stats Summary:** Total prints, success rate, total electricity used. - **Stats Summary:** Total prints, success rate, total electricity used.
### 6. User Management ### 7. User Management
- **Authentication:** Secure login/registration with JWT tokens. - **Authentication:** Secure login/registration with JWT tokens.
- **User Settings:** Profile editing and password change. - **User Settings:**
- Profile editing (username, location).
- Electricity rate configuration ($/kWh).
- Password change.
- **Admin Panel:** Manage users (Admin role only). - **Admin Panel:** Manage users (Admin role only).
- **Role-Based Access:** Admin and User roles with appropriate permissions. - **Role-Based Access:** Admin and User roles with appropriate permissions.
@@ -74,9 +97,11 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- `_id`: ObjectId - `_id`: ObjectId
- `username`: String (Required, Unique) - `username`: String (Required, Unique)
- `email`: String
- `password`: String (Hashed with bcrypt) - `password`: String (Hashed with bcrypt)
- `role`: String (Enum: User, Admin) - `role`: String (Enum: User, Admin)
- `location`: String
- `electricity_rate`: Number (Default: 0.12 $/kWh)
- `currency`: String (Default: USD)
- `createdAt`: Date - `createdAt`: Date
### Spool Schema ### Spool Schema
@@ -110,9 +135,11 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- `name`: String - `name`: String
- `duration_minutes`: Number - `duration_minutes`: Number
- `filament_used_g`: Number - `filament_used_g`: Number
- `calculated_cost_filament`: Number - `calculated_cost_filament`: Number (Total cost including electricity)
- `calculated_cost_energy`: Number (Electricity cost only)
- `status`: String (Enum: Success, Fail, Cancelled, In Progress) - `status`: String (Enum: Success, Fail, Cancelled, In Progress)
- `started_at`: Date (For In Progress jobs) - `started_at`: Date (For In Progress jobs)
- `stl_file`: String (Path to uploaded 3D model)
- `date`: Date (Default: Date.now) - `date`: Date (Default: Date.now)
## 🚀 Getting Started ## 🚀 Getting Started
@@ -121,8 +148,9 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- Node.js 18+ or Bun - Node.js 18+ or Bun
- MongoDB instance (local or Atlas) - MongoDB instance (local or Atlas)
- Docker (optional, for containerized deployment)
### Installation ### Local Development
```bash ```bash
# Clone the repository # Clone the repository
@@ -138,36 +166,79 @@ cp .env.example .env
# Run development server # Run development server
bun run dev bun run dev
# Build for production
bun run build
# Start production server
bun run start
```
### Docker Deployment
```bash
# Copy environment file
cp .env.example .env
# Edit .env with secure values
# Build and start containers
docker compose up -d --build
# View logs
docker compose logs -f filaprint
# Stop containers
docker compose down
``` ```
### Environment Variables ### Environment Variables
```env ```env
# MongoDB Connection
MONGODB_URI=mongodb://localhost:27017/filaprint MONGODB_URI=mongodb://localhost:27017/filaprint
# JWT Secret (use a secure random string in production)
JWT_SECRET=your-super-secret-jwt-key JWT_SECRET=your-super-secret-jwt-key
# Application Origin (required for CSRF protection)
ORIGIN=http://localhost:3000
# Docker MongoDB Settings
MONGO_USER=admin
MONGO_PASSWORD=changeme
``` ```
## 📁 Project Structure ## 📁 Project Structure
``` ```
src/ filaprint/
├── lib/ ├── src/
│ ├── components/ │ ├── lib/
│ │ ├── ui/ # Base UI components (Button, Card, Input, Modal) │ │ ├── components/
│ │ ├── prints/ # Print-specific components (LogPrintModal, EditPrintModal) │ │ │ ├── ui/ # Base UI components (Button, Card, Input, Modal)
│ │ └── Navbar.svelte │ │ │ ├── prints/ # Print-specific components (LogPrintModal, EditPrintModal)
├── models/ # Mongoose schemas ├── STLViewer.svelte # 3D model viewer (STL & OBJ)
└── server/ # Server utilities (db connection, auth) └── Navbar.svelte
├── routes/ │ │ ├── models/ # Mongoose schemas
├── admin/users/ # Admin user management │ └── server/ # Server utilities (db connection, auth)
│ ├── analytics/ # Analytics dashboard │ ├── routes/
│ ├── login/ # Authentication │ ├── admin/users/ # Admin user management
│ ├── printers/ # Printer management │ ├── analytics/ # Analytics dashboard
│ ├── prints/ # Print job logging │ ├── api/upload-stl/ # 3D model upload endpoint
│ ├── register/ # User registration │ ├── library/ # 3D model library
│ ├── settings/ # User settings │ ├── login/ # Authentication
└── spools/ # Filament inventory │ ├── printers/ # Printer management
└── app.css # Global styles (Cerberus theme) ├── prints/ # Print job logging
│ │ ├── register/ # User registration
│ │ ├── settings/ # User settings
│ │ └── spools/ # Filament inventory
│ └── app.css # Global styles (Cerberus theme)
├── static/
│ └── uploads/models/ # Uploaded 3D model files
├── server/ # Production server
├── Dockerfile # Container build instructions
├── docker-compose.yml # Container orchestration
└── package.json
``` ```
## ✅ Completed Features ## ✅ Completed Features
@@ -177,18 +248,23 @@ src/
- [x] Spool management (CRUD) - [x] Spool management (CRUD)
- [x] Printer management (CRUD) - [x] Printer management (CRUD)
- [x] Print job logging with "In Progress" support - [x] Print job logging with "In Progress" support
- [x] Cost calculation (auto and manual) - [x] Duration input with hours/minutes fields
- [x] Cost calculation (filament + electricity)
- [x] User-configurable electricity rate
- [x] Filament deduction on print completion - [x] Filament deduction on print completion
- [x] Analytics with Chart.js (filament, electricity, materials) - [x] Analytics with Chart.js (filament, electricity, materials)
- [x] User settings (profile, password change) - [x] 3D Model Library with interactive viewer
- [x] STL and OBJ file upload with progress bar
- [x] Mobile hamburger menu (solid background)
- [x] User settings (profile, location, electricity rate, password)
- [x] Admin user management panel - [x] Admin user management panel
- [x] Browser notifications for completed prints - [x] Browser notifications for completed prints
- [x] Iconify icon library integration - [x] Iconify icon library integration
- [x] Responsive design - [x] Responsive design
- [x] Docker containerization
## 🔮 Future Enhancements ## 🔮 Future Enhancements
- [ ] 3D spool visualization with Threlte
- [ ] QR/Barcode scanning for quick spool lookup - [ ] QR/Barcode scanning for quick spool lookup
- [ ] Photo uploads for print jobs - [ ] Photo uploads for print jobs
- [ ] Export data (CSV/PDF reports) - [ ] Export data (CSV/PDF reports)
@@ -196,3 +272,9 @@ src/
- [ ] Dark/Light theme toggle - [ ] Dark/Light theme toggle
- [ ] Email notifications - [ ] Email notifications
- [ ] Print job templates - [ ] Print job templates
- [ ] 3D printer integration (OctoPrint, Klipper)
- [ ] Thumbnail generation for 3D models
## 📄 License
MIT License - See LICENSE file for details.

View File

@@ -3,6 +3,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
interface Props { interface Props {
@@ -95,6 +96,8 @@
if (extension === "obj") { if (extension === "obj") {
loadOBJ(); loadOBJ();
} else if (extension === "gltf" || extension === "glb") {
loadGLTF();
} else { } else {
loadSTL(); loadSTL();
} }
@@ -161,6 +164,56 @@
); );
} }
function loadGLTF() {
const loader = new GLTFLoader();
loader.load(
modelPath,
(gltf) => {
const model = gltf.scene;
// Center the object
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
// Scale to fit
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 50 / maxDim;
model.scale.set(scale, scale, scale);
// glTF models may have their own materials, apply default if missing
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (
!child.material ||
(child.material as THREE.Material).type ===
"MeshBasicMaterial"
) {
child.material = new THREE.MeshPhongMaterial({
color: 0x3b82f6,
specular: 0x111111,
shininess: 50,
});
}
}
});
scene.add(model);
// Position camera
const distance = maxDim * scale * 2.5;
camera.position.set(distance, distance, distance);
controls.update();
},
undefined,
(err) => {
console.error("Error loading glTF:", err);
},
);
}
function addGeometryToScene(geometry: THREE.BufferGeometry) { function addGeometryToScene(geometry: THREE.BufferGeometry) {
// Center the geometry // Center the geometry
geometry.computeBoundingBox(); geometry.computeBoundingBox();

View File

@@ -101,8 +101,12 @@
stlFile = input.files[0]; stlFile = input.files[0];
uploadStatus = stlFile.name; uploadStatus = stlFile.name;
uploadProgress = 0; uploadProgress = 0;
removeModel = false; // Reset remove flag if selecting new file
} }
} }
// Track if user wants to remove the model
let removeModel = $state(false);
</script> </script>
<Modal title="Edit Print Log" {open} onclose={handleClose}> <Modal title="Edit Print Log" {open} onclose={handleClose}>
@@ -121,6 +125,11 @@
} }
} }
// Handle model removal
if (removeModel) {
formData.set("remove_model", "true");
}
// Convert hours + minutes to total minutes // Convert hours + minutes to total minutes
const hours = Number(formData.get("duration_hours") || 0); const hours = Number(formData.get("duration_hours") || 0);
const mins = Number(formData.get("duration_mins") || 0); const mins = Number(formData.get("duration_mins") || 0);
@@ -222,22 +231,53 @@
3D Model {print.stl_file ? "" : "(Optional)"} 3D Model {print.stl_file ? "" : "(Optional)"}
</label> </label>
{#if print.stl_file && browser && !stlFile} {#if print.stl_file && browser && !stlFile && !removeModel}
<!-- Show existing STL viewer --> <!-- Show existing STL viewer -->
<div <div class="relative">
class="flex justify-center bg-slate-900 rounded-lg p-2" <div
> class="flex justify-center bg-slate-900 rounded-lg p-2"
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }} >
<STLViewer {#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
modelPath={print.stl_file} <STLViewer
width={400} modelPath={print.stl_file}
height={250} width={400}
/> height={250}
{/await} />
{/await}
</div>
<!-- Remove button overlay -->
<button
type="button"
class="absolute top-4 right-4 p-2 bg-red-500/80 hover:bg-red-500 text-white rounded-lg transition-colors"
onclick={() => (removeModel = true)}
title="Remove 3D model"
>
<Icon icon="mdi:delete" class="w-5 h-5" />
</button>
</div> </div>
<p class="text-xs text-slate-500 text-center"> <p class="text-xs text-slate-500 text-center">
Click below to replace with a new model Click below to replace with a new model
</p> </p>
{:else if removeModel && print.stl_file}
<!-- Removal confirmation -->
<div
class="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-center"
>
<Icon
icon="mdi:file-remove"
class="w-10 h-10 text-red-400 mx-auto mb-2"
/>
<p class="text-sm text-red-300 mb-3">
Model will be removed when you save
</p>
<button
type="button"
class="text-xs text-slate-400 hover:text-white underline"
onclick={() => (removeModel = false)}
>
Cancel removal
</button>
</div>
{/if} {/if}
<!-- Upload button or progress --> <!-- Upload button or progress -->
@@ -280,7 +320,7 @@
</div> </div>
<input <input
type="file" type="file"
accept=".stl,.obj" accept=".stl,.obj,.gltf,.glb"
class="sr-only" class="sr-only"
onchange={handleFileSelect} onchange={handleFileSelect}
/> />

View File

@@ -239,7 +239,7 @@
</div> </div>
<input <input
type="file" type="file"
accept=".stl,.obj" accept=".stl,.obj,.gltf,.glb"
class="sr-only" class="sr-only"
onchange={handleFileSelect} onchange={handleFileSelect}
/> />

View File

@@ -5,7 +5,7 @@ import { existsSync } from 'fs';
import path from 'path'; import path from 'path';
const UPLOAD_DIR = 'static/uploads/models'; const UPLOAD_DIR = 'static/uploads/models';
const ALLOWED_EXTENSIONS = ['.stl', '.obj']; const ALLOWED_EXTENSIONS = ['.stl', '.obj', '.gltf', '.glb'];
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) { if (!locals.user) {
@@ -24,7 +24,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const extension = '.' + fileName.split('.').pop(); const extension = '.' + fileName.split('.').pop();
if (!ALLOWED_EXTENSIONS.includes(extension)) { if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw error(400, 'Only STL and OBJ files are allowed'); throw error(400, 'Only STL, OBJ, GLTF, and GLB files are allowed');
} }
// Create upload directory if it doesn't exist // Create upload directory if it doesn't exist

View File

@@ -5,6 +5,9 @@ import { User } from '$lib/models/User';
import { connectDB } from '$lib/server/db'; import { connectDB } from '$lib/server/db';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { unlink } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login'); if (!locals.user) throw redirect(303, '/login');
@@ -142,6 +145,7 @@ export const actions: Actions = {
const printer_id = formData.get('printer_id'); const printer_id = formData.get('printer_id');
const spool_id = formData.get('spool_id'); const spool_id = formData.get('spool_id');
const stl_file = formData.get('stl_file'); const stl_file = formData.get('stl_file');
const remove_model = formData.get('remove_model');
if (!id || !name) { if (!id || !name) {
return fail(400, { missing: true }); return fail(400, { missing: true });
@@ -218,9 +222,31 @@ export const actions: Actions = {
if (spool_id) { if (spool_id) {
updateData.spool_id = spool_id; updateData.spool_id = spool_id;
} }
// Update STL file if provided // Handle STL file: update if new one provided, or remove if requested
if (stl_file) { if (stl_file) {
// If replacing an existing model, delete the old file
if (printJob.stl_file) {
const oldFilePath = path.join('static', printJob.stl_file);
if (existsSync(oldFilePath)) {
try {
await unlink(oldFilePath);
} catch (e) {
console.error('Failed to delete old model file:', e);
}
}
}
updateData.stl_file = stl_file; updateData.stl_file = stl_file;
} else if (remove_model === 'true' && printJob.stl_file) {
// Delete the file from disk
const filePath = path.join('static', printJob.stl_file);
if (existsSync(filePath)) {
try {
await unlink(filePath);
} catch (e) {
console.error('Failed to delete model file:', e);
}
}
updateData.stl_file = null;
} }
await PrintJob.findOneAndUpdate( await PrintJob.findOneAndUpdate(
@@ -246,6 +272,21 @@ export const actions: Actions = {
await connectDB(); await connectDB();
try { try {
// Find the print first to get the model file path
const printJob = await PrintJob.findOne({ _id: id, user_id: locals.user.id });
if (printJob?.stl_file) {
// Delete the model file from disk
const filePath = path.join('static', printJob.stl_file);
if (existsSync(filePath)) {
try {
await unlink(filePath);
} catch (e) {
console.error('Failed to delete model file:', e);
}
}
}
await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id }); await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {