Update 0.2.0

This commit is contained in:
2026-01-17 20:55:48 +00:00
parent 54bb58e5b0
commit eabc821e61
12 changed files with 684 additions and 634 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sir Blob
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

136
README.md
View File

@@ -4,21 +4,18 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
![Filaprint Dashboard](https://img.shields.io/badge/Filaprint-3D%20Print%20Manager-blue?style=for-the-badge) ![Filaprint Dashboard](https://img.shields.io/badge/Filaprint-3D%20Print%20Manager-blue?style=for-the-badge)
## 🛠️ Technology Stack ## Technology Stack
- **Framework:** SvelteKit (Svelte 5) - **Framework:** SvelteKit
- **Language:** TypeScript - **Language:** TypeScript
- **Styling:** Tailwind CSS v4 (Cerberus Theme) - **Styling:** Tailwind CSS
- **State Management:** Svelte 5 Runes
- **Build Tool:** Vite
- **3D Rendering:** Three.js (STL & OBJ loaders) - **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
- **Authentication:** JWT with bcrypt password hashing
- **Container:** Docker with Docker Compose - **Container:** Docker with Docker Compose
## Features ## Features
### 1. Dashboard ### 1. Dashboard
@@ -91,58 +88,7 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- **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.
## 🗂️ Data Models (Mongoose Schemas) ## Getting Started
### User Schema
- `_id`: ObjectId
- `username`: String (Required, Unique)
- `password`: String (Hashed with bcrypt)
- `role`: String (Enum: User, Admin)
- `location`: String
- `electricity_rate`: Number (Default: 0.12 $/kWh)
- `currency`: String (Default: USD)
- `createdAt`: Date
### Spool Schema
- `_id`: ObjectId
- `user_id`: ObjectId (Ref: User)
- `brand`: String (Required)
- `material`: String (Required, Enum: PLA, PETG, ABS, ASA, TPU, Other)
- `color_hex`: String (Default: #ffffff)
- `weight_initial_g`: Number (Required)
- `weight_remaining_g`: Number (Required)
- `price`: Number
- `purchased_at`: Date
- `is_active`: Boolean (Default: true)
### Printer Schema
- `_id`: ObjectId
- `user_id`: ObjectId (Ref: User)
- `name`: String (Required)
- `model`: String
- `nozzle_diameter_mm`: Number (Default: 0.4)
- `power_consumption_watts`: Number (Default: 0)
### PrintJob Schema
- `_id`: ObjectId
- `user_id`: ObjectId (Ref: User)
- `printer_id`: ObjectId (Ref: Printer)
- `spool_id`: ObjectId (Ref: Spool)
- `name`: String
- `duration_minutes`: Number
- `filament_used_g`: Number
- `calculated_cost_filament`: Number (Total cost including electricity)
- `calculated_cost_energy`: Number (Electricity cost only)
- `status`: String (Enum: Success, Fail, Cancelled, In Progress)
- `started_at`: Date (For In Progress jobs)
- `stl_file`: String (Path to uploaded 3D model)
- `date`: Date (Default: Date.now)
## 🚀 Getting Started
### Prerequisites ### Prerequisites
@@ -150,30 +96,6 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
- MongoDB instance (local or Atlas) - MongoDB instance (local or Atlas)
- Docker (optional, for containerized deployment) - Docker (optional, for containerized deployment)
### Local Development
```bash
# Clone the repository
git clone https://github.com/yourusername/filaprint.git
cd filaprint
# Install dependencies
bun install
# Set up environment variables
cp .env.example .env
# Edit .env with your MongoDB URI and JWT secret
# Run development server
bun run dev
# Build for production
bun run build
# Start production server
bun run start
```
### Docker Deployment ### Docker Deployment
```bash ```bash
@@ -208,40 +130,7 @@ MONGO_USER=admin
MONGO_PASSWORD=changeme MONGO_PASSWORD=changeme
``` ```
## 📁 Project Structure ## Completed Features
```
filaprint/
├── src/
│ ├── lib/
│ │ ├── components/
│ │ │ ├── ui/ # Base UI components (Button, Card, Input, Modal)
│ │ │ ├── prints/ # Print-specific components (LogPrintModal, EditPrintModal)
│ │ │ ├── STLViewer.svelte # 3D model viewer (STL & OBJ)
│ │ │ └── Navbar.svelte
│ │ ├── models/ # Mongoose schemas
│ │ └── server/ # Server utilities (db connection, auth)
│ ├── routes/
│ │ ├── admin/users/ # Admin user management
│ │ ├── analytics/ # Analytics dashboard
│ │ ├── api/upload-stl/ # 3D model upload endpoint
│ │ ├── library/ # 3D model library
│ │ ├── login/ # Authentication
│ │ ├── printers/ # Printer management
│ │ ├── 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
- [x] User authentication (Login/Register) - [x] User authentication (Login/Register)
- [x] Dashboard with live stats and active print tracking - [x] Dashboard with live stats and active print tracking
@@ -263,18 +152,13 @@ filaprint/
- [x] Responsive design - [x] Responsive design
- [x] Docker containerization - [x] Docker containerization
## 🔮 Future Enhancements ## Future Features
- [ ] QR/Barcode scanning for quick spool lookup - [ ] QR/Barcode scanning for quick spool lookup
- [ ] Photo uploads for print jobs
- [ ] Export data (CSV/PDF reports)
- [ ] Multi-language support - [ ] Multi-language support
- [ ] Dark/Light theme toggle - [ ] Some notifications
- [ ] Email notifications
- [ ] Print job templates
- [ ] 3D printer integration (OctoPrint, Klipper)
- [ ] Thumbnail generation for 3D models - [ ] Thumbnail generation for 3D models
## 📄 License ## License
MIT License - See LICENSE file for details. MIT License - See LICENSE file for details.

View File

@@ -5,10 +5,12 @@
"": { "": {
"name": "filaprint", "name": "filaprint",
"dependencies": { "dependencies": {
"@iconify/svelte": "^5.1.0", "@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.1",
"@threlte/core": "^8.3.1", "@threlte/core": "^8.3.1",
"@threlte/extras": "^9.7.1", "@threlte/extras": "^9.7.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -17,24 +19,24 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "^9.0.2", "mongoose": "^9.1.4",
"three": "^0.182.0", "three": "^0.182.0",
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/cookie": "^1.0.0", "@types/cookie": "^1.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"svelte": "^5.45.6", "svelte": "^5.46.4",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6", "vite": "^7.3.1",
}, },
}, },
}, },
@@ -93,7 +95,7 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@iconify/svelte": ["@iconify/svelte@5.1.0", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "svelte": ">4.0.0" } }, "sha512-I14nSqo0pNXO5OKsT61ZO3XIPF4yRHA2ErgPsaZ1sPJdKXn80o7o8jOe1xpWphbb9FihdX6by9zlKKBss61mFw=="], "@iconify/svelte": ["@iconify/svelte@5.2.1", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "svelte": ">5.0.0" } }, "sha512-zHmsIPmnIhGd5gc95bNN5FL+GifwMZv7M2rlZEpa7IXYGFJm/XGHdWf6PWQa6OBoC+R69WyiPO9NAj5wjfjbow=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
@@ -171,11 +173,11 @@
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="], "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="], "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.1", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-VpZdPNRPQuZRtgfAMETPWWKpZx9JwXmUUsgz/+eSpw/Oh7+2O1uZHlsQTuyfxydJHPrRzjfu/ItcJjY4oscCiQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="], "@sveltejs/kit": ["@sveltejs/kit@2.50.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Hj8sR8O27p2zshFEIJzsvfhLzxga/hWw6tRLnBjMYw70m1aS9BSYCqAUtzDBjRREtX1EvLMYgaC0mYE3Hz4KWA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
@@ -223,18 +225,38 @@
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="], "@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="], "@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="],
@@ -301,7 +323,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"diet-sprite": ["diet-sprite@0.0.1", "", {}, "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A=="], "diet-sprite": ["diet-sprite@0.0.1", "", {}, "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A=="],
@@ -459,7 +481,7 @@
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="], "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="],
"mongoose": ["mongoose@9.0.2", "", { "dependencies": { "kareem": "3.0.0", "mongodb": "~7.0", "mpath": "0.9.0", "mquery": "6.0.0", "ms": "2.1.3", "sift": "17.1.3" } }, "sha512-+GCaqwE+X//yN9eo2M2L/n+mVti9J6vH5iQKbhD+2AArZd5iaZqK/DkmkE4S6/iYYMyVQPTXsRk7jyVOYEtJzA=="], "mongoose": ["mongoose@9.1.4", "", { "dependencies": { "kareem": "3.0.0", "mongodb": "~7.0", "mpath": "0.9.0", "mquery": "6.0.0", "ms": "2.1.3", "sift": "17.1.3" } }, "sha512-V8JIyoWKWW+R2COlOsh6gaYw9TvczSiP/cN3Yuk1pv7ws5VNFAy5GPrK8jfz9tVYovmqdWOJRurMjL4ilYn9wA=="],
"mpath": ["mpath@0.9.0", "", {}, "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="], "mpath": ["mpath@0.9.0", "", {}, "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="],
@@ -479,6 +501,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -553,7 +577,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.46.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA=="], "svelte": ["svelte@5.46.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w=="],
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="], "svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
@@ -599,7 +623,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],

View File

@@ -1,34 +1,36 @@
{ {
"name": "filaprint", "name": "filaprint",
"private": true, "private": true,
"version": "0.0.1", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "vite build", "build": "vite build",
"start": "node server/server.js" "start": "bun server/server.ts"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/cookie": "^1.0.0", "@types/cookie": "^1.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"svelte": "^5.45.6", "svelte": "^5.46.4",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"@iconify/svelte": "^5.1.0", "@iconify/svelte": "^5.2.1",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.1",
"@threlte/core": "^8.3.1", "@threlte/core": "^8.3.1",
"@threlte/extras": "^9.7.1", "@threlte/extras": "^9.7.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
@@ -37,7 +39,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mongoose": "^9.0.2", "mongoose": "^9.1.4",
"three": "^0.182.0" "three": "^0.182.0"
} }
} }

View File

@@ -3,9 +3,9 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/filaprint'; const MONGODB_URI: string = process.env.MONGODB_URI || 'mongodb://localhost:27017/filaprint';
export const connectDB = async () => { export const connectDB = async (): Promise<void> => {
try { try {
await mongoose.connect(MONGODB_URI); await mongoose.connect(MONGODB_URI);
console.log('MongoDB connected successfully'); console.log('MongoDB connected successfully');

View File

@@ -1,14 +1,15 @@
// @ts-ignore
import { handler } from '../build/handler.js'; import { handler } from '../build/handler.js';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import http from 'http'; import http from 'http';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import { connectDB } from './db.js'; import { connectDB } from './db';
dotenv.config(); dotenv.config();
const app = express(); const app = express();
const server = http.Server(app); const server = http.createServer(app);
app.use(cors()); app.use(cors());

View File

@@ -123,6 +123,23 @@
// Track if user wants to remove the model // Track if user wants to remove the model
let removeModel = $state(false); let removeModel = $state(false);
// Elapsed time state for In Progress prints
let elapsedHours = $state(0);
let elapsedMins = $state(0);
$effect(() => {
if (print && print.status === "In Progress" && print.started_at) {
const start = new Date(print.started_at).getTime();
const now = Date.now();
const diffMins = Math.max(0, Math.floor((now - start) / 60000));
elapsedHours = Math.floor(diffMins / 60);
elapsedMins = diffMins % 60;
} else {
elapsedHours = 0;
elapsedMins = 0;
}
});
</script> </script>
<Modal title="Edit Print Log" {open} onclose={handleClose}> <Modal title="Edit Print Log" {open} onclose={handleClose}>
@@ -495,7 +512,7 @@
name="elapsed_hours" name="elapsed_hours"
placeholder="0" placeholder="0"
min="0" min="0"
value="0" bind:value={elapsedHours}
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
/> />
<span <span
@@ -510,7 +527,7 @@
placeholder="0" placeholder="0"
min="0" min="0"
max="59" max="59"
value="0" bind:value={elapsedMins}
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
/> />
<span <span

View File

@@ -10,9 +10,10 @@
printers: any[]; printers: any[];
spools: any[]; spools: any[];
onclose: () => void; onclose: () => void;
action?: string;
} }
let { open, printers, spools, onclose }: Props = $props(); let { open, printers, spools, onclose, action = "?/log" }: Props = $props();
let isSubmitting = $state(false); let isSubmitting = $state(false);
let selectedStatus = $state("Success"); let selectedStatus = $state("Success");
let stlFile = $state<File | null>(null); let stlFile = $state<File | null>(null);
@@ -85,7 +86,7 @@
<Modal title="Log a Print" {open} onclose={handleClose}> <Modal title="Log a Print" {open} onclose={handleClose}>
<form <form
method="POST" method="POST"
action="?/log" {action}
use:enhance={async ({ formData }) => { use:enhance={async ({ formData }) => {
isSubmitting = true; isSubmitting = true;
@@ -296,8 +297,11 @@
name="printer_id" name="printer_id"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none"
> >
<option value="" disabled selected>Select a printer</option>
{#each printers as p} {#each printers as p}
<option value={p._id}>{p.name}</option> <option value={p._id}>{p.name}</option>
{:else}
<option value="" disabled>No printers found</option>
{/each} {/each}
</select> </select>
</div> </div>
@@ -311,11 +315,14 @@
name="spool_id" name="spool_id"
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none"
> >
<option value="" disabled selected>Select a spool</option>
{#each spools as s} {#each spools as s}
<option value={s._id} <option value={s._id}
>{s.brand} >{s.brand}
{s.material} ({s.weight_remaining_g}g left)</option {s.material} ({s.weight_remaining_g}g left)</option
> >
{:else}
<option value="" disabled>No spools found</option>
{/each} {/each}
</select> </select>
</div> </div>

View File

@@ -1,54 +1,54 @@
<script lang="ts"> <script lang="ts">
import { fade, scale } from "svelte/transition"; import { fade, scale } from "svelte/transition";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
interface Props { interface Props {
open: boolean; open: boolean;
title: string; title: string;
children: Snippet; children: Snippet;
onclose: () => void; onclose: () => void;
} }
let { open, title, children, onclose }: Props = $props(); let { open, title, children, onclose }: Props = $props();
</script> </script>
{#if open} {#if open}
<div <div
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
> >
<!-- Backdrop --> <!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 bg-black/70" onclick={onclose}></div> <div class="fixed inset-0 bg-black/70" onclick={onclose}></div>
<!-- Dialog Panel --> <!-- Dialog Panel -->
<div <div
class="relative z-10 w-full max-w-lg rounded-xl border border-[#3f3f46] shadow-2xl" class="relative z-10 w-full max-w-lg rounded-xl border border-[#3f3f46] shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
style="background-color: #18181b;" style="background-color: #18181b;"
transition:scale={{ duration: 150, start: 0.95 }} transition:scale={{ duration: 150, start: 0.95 }}
> >
<!-- Header --> <!-- Header -->
<div <div
class="flex items-center justify-between border-b border-[#3f3f46] px-6 py-4" class="flex items-center justify-between border-b border-[#3f3f46] px-6 py-4 shrink-0"
style="background-color: #27272a;" style="background-color: #27272a;"
> >
<h3 class="text-lg font-semibold text-white">{title}</h3> <h3 class="text-lg font-semibold text-white">{title}</h3>
<button <button
onclick={onclose} onclick={onclose}
class="rounded p-1 text-[#a1a1aa] transition-colors hover:bg-[#3f3f46] hover:text-white" class="rounded p-1 text-[#a1a1aa] transition-colors hover:bg-[#3f3f46] hover:text-white"
aria-label="Close" aria-label="Close"
type="button" type="button"
> >
<Icon icon="mdi:close" class="h-5 w-5" /> <Icon icon="mdi:close" class="h-5 w-5" />
</button> </button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="p-6"> <div class="p-6 overflow-y-auto">
{@render children()} {@render children()}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -7,18 +7,19 @@ import { redirect } from '@sveltejs/kit';
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');
await connectDB(); await connectDB();
const userId = locals.user.id; const userId = locals.user.id;
// Run in parallel - all filtered by user // Run in parallel - all filtered by user
const [spoolCount, spools, printerCount, recentPrints, activePrinter, activePrintJob, completedPrints] = await Promise.all([ const [spoolCount, spools, printerCount, recentPrints, activePrinter, printers, activePrintJob, completedPrints] = await Promise.all([
Spool.countDocuments({ user_id: userId, is_active: true }), Spool.countDocuments({ user_id: userId, is_active: true }),
Spool.find({ user_id: userId, is_active: true }).lean(), Spool.find({ user_id: userId, is_active: true }).lean(),
Printer.countDocuments({ user_id: userId }), Printer.countDocuments({ user_id: userId }),
PrintJob.find({ user_id: userId }).sort({ date: -1 }).limit(5).populate('printer_id', 'name').populate('spool_id', 'brand color_hex').lean(), PrintJob.find({ user_id: userId }).sort({ date: -1, _id: -1 }).limit(5).populate('printer_id', 'name').populate('spool_id', 'brand color_hex').lean(),
Printer.findOne({ user_id: userId }).lean(), Printer.findOne({ user_id: userId }).lean(),
Printer.find({ user_id: userId }).lean(), // Fetch all printers for dropdowns
PrintJob.findOne({ user_id: userId, status: 'In Progress' }).populate('printer_id', 'name').populate('spool_id', 'brand color_hex material').lean(), PrintJob.findOne({ user_id: userId, status: 'In Progress' }).populate('printer_id', 'name').populate('spool_id', 'brand color_hex material').lean(),
PrintJob.find({ user_id: userId, status: { $ne: 'In Progress' } }).select('calculated_cost_filament').lean() PrintJob.find({ user_id: userId, status: { $ne: 'In Progress' } }).select('calculated_cost_filament').lean()
]); ]);
@@ -29,7 +30,7 @@ export const load: PageServerLoad = async ({ locals }) => {
spools.forEach(spool => { spools.forEach(spool => {
totalWeightG += (spool.weight_remaining_g || 0); totalWeightG += (spool.weight_remaining_g || 0);
// Value = (Remaining / Initial) * Price // Value = (Remaining / Initial) * Price
if (spool.weight_initial_g > 0 && spool.price > 0) { if (spool.weight_initial_g > 0 && spool.price > 0) {
const ratio = (spool.weight_remaining_g || 0) / spool.weight_initial_g; const ratio = (spool.weight_remaining_g || 0) / spool.weight_initial_g;
@@ -44,8 +45,8 @@ export const load: PageServerLoad = async ({ locals }) => {
}); });
// Keep grams for precision, format for display // Keep grams for precision, format for display
const totalWeightKg = totalWeightG >= 1000 const totalWeightKg = totalWeightG >= 1000
? (totalWeightG / 1000).toFixed(2) ? (totalWeightG / 1000).toFixed(2)
: (totalWeightG / 1000).toFixed(3); : (totalWeightG / 1000).toFixed(3);
const estimatedValue = totalValue.toFixed(2); const estimatedValue = totalValue.toFixed(2);
@@ -60,6 +61,8 @@ export const load: PageServerLoad = async ({ locals }) => {
}, },
recentPrints: JSON.parse(JSON.stringify(recentPrints)), recentPrints: JSON.parse(JSON.stringify(recentPrints)),
activePrinter: activePrinter ? JSON.parse(JSON.stringify(activePrinter)) : null, activePrinter: activePrinter ? JSON.parse(JSON.stringify(activePrinter)) : null,
activePrintJob: activePrintJob ? JSON.parse(JSON.stringify(activePrintJob)) : null activePrintJob: activePrintJob ? JSON.parse(JSON.stringify(activePrintJob)) : null,
spools: JSON.parse(JSON.stringify(spools)),
printers: JSON.parse(JSON.stringify(printers))
}; };
}; };

View File

@@ -1,426 +1,517 @@
<script lang="ts"> <script lang="ts">
import Card from "$lib/components/ui/Card.svelte"; import Card from "$lib/components/ui/Card.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
let { data } = $props(); import LogPrintModal from "$lib/components/prints/LogPrintModal.svelte";
// svelte-ignore non_reactive_update
let stats = $derived(data.stats);
// svelte-ignore non_reactive_update
let recentPrints = $derived(data.recentPrints || []);
// svelte-ignore non_reactive_update
let activePrinter = $derived(data.activePrinter);
// svelte-ignore non_reactive_update
let activePrintJob = $derived(data.activePrintJob);
// Timer state let { data } = $props();
let currentTime = $state(Date.now()); // svelte-ignore non_reactive_update
let timerInterval: ReturnType<typeof setInterval>; let stats = $derived(data.stats);
let notificationSent = $state(false); // svelte-ignore non_reactive_update
let recentPrints = $derived(data.recentPrints || []);
// svelte-ignore non_reactive_update
let activePrinter = $derived(data.activePrinter);
// svelte-ignore non_reactive_update
let activePrintJob = $derived(data.activePrintJob);
// svelte-ignore non_reactive_update
let spools = $derived(data.spools || []);
// svelte-ignore non_reactive_update
let printers = $derived(data.printers || []);
onMount(() => { let showQuickLogModal = $state(false);
// Request notification permission
if (
browser &&
"Notification" in window &&
Notification.permission === "default"
) {
Notification.requestPermission();
}
// Update timer every 10 seconds // Timer state
timerInterval = setInterval(() => { let currentTime = $state(Date.now());
currentTime = Date.now(); let timerInterval: ReturnType<typeof setInterval>;
let notificationSent = $state(false);
// Check if print is complete // ... (rest of onMount/onDestroy/notifications - keeping as is)
if (activePrintJob && !notificationSent) { onMount(() => {
const progress = getProgress( // Request notification permission
activePrintJob.started_at, if (
activePrintJob.duration_minutes browser &&
); "Notification" in window &&
if (progress >= 100) { Notification.permission === "default"
sendNotification(); ) {
notificationSent = true; Notification.requestPermission();
} }
}
}, 10000);
});
onDestroy(() => { // Update timer every 10 seconds
if (timerInterval) clearInterval(timerInterval); timerInterval = setInterval(() => {
}); currentTime = Date.now();
function sendNotification() { // Check if print is complete
if ( if (activePrintJob && !notificationSent) {
browser && const progress = getProgress(
"Notification" in window && activePrintJob.started_at,
Notification.permission === "granted" activePrintJob.duration_minutes,
) { );
new Notification("🎉 Print Complete!", { if (progress >= 100) {
body: `Your print "${activePrintJob?.name}" has finished!`, sendNotification();
icon: "/favicon.png", notificationSent = true;
}); }
} }
} }, 10000);
});
// Calculate time remaining for active print onDestroy(() => {
function getTimeRemaining( if (timerInterval) clearInterval(timerInterval);
startedAt: string, });
durationMinutes: number
): string {
if (!startedAt) return "--:--";
const start = new Date(startedAt).getTime();
const elapsed = Math.floor((currentTime - start) / 60000); // minutes
const remaining = Math.max(0, durationMinutes - elapsed);
if (remaining === 0) return "Complete!";
const hours = Math.floor(remaining / 60);
const mins = remaining % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
function getProgress(startedAt: string, durationMinutes: number): number { function sendNotification() {
if (!startedAt || !durationMinutes) return 0; if (
const start = new Date(startedAt).getTime(); browser &&
const elapsed = (currentTime - start) / 60000; "Notification" in window &&
return Math.min(100, Math.max(0, (elapsed / durationMinutes) * 100)); Notification.permission === "granted"
} ) {
new Notification("🎉 Print Complete!", {
body: `Your print "${activePrintJob?.name}" has finished!`,
icon: "/favicon.png",
});
}
}
// Calculate time remaining for active print
function getTimeRemaining(
startedAt: string,
durationMinutes: number,
): string {
if (!startedAt) return "--:--";
const start = new Date(startedAt).getTime();
const elapsed = Math.floor((currentTime - start) / 60000); // minutes
const remaining = Math.max(0, durationMinutes - elapsed);
if (remaining === 0) return "Complete!";
const hours = Math.floor(remaining / 60);
const mins = remaining % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
function getProgress(startedAt: string, durationMinutes: number): number {
if (!startedAt || !durationMinutes) return 0;
const start = new Date(startedAt).getTime();
const elapsed = (currentTime - start) / 60000;
return Math.min(100, Math.max(0, (elapsed / durationMinutes) * 100));
}
</script> </script>
<div class="space-y-8 fade-in"> <div class="space-y-8 fade-in">
<!-- Header --> <!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div
<div> class="flex flex-col md:flex-row md:items-center justify-between gap-4"
<h1 class="text-3xl font-bold tracking-tight text-white/90">Dashboard</h1> >
<p class="text-slate-400 mt-1">Welcome back to your printing hub.</p> <div>
</div> <h1 class="text-3xl font-bold tracking-tight text-white/90">
<div class="flex gap-3"> Dashboard
<Button variant="secondary" size="sm">Quick Log</Button> </h1>
<a href="/prints"> <p class="text-slate-400 mt-1">
<Button variant="primary" size="sm" class="shadow-blue-500/20"> Welcome back to your printing hub.
<span class="mr-2 text-lg leading-none">+</span> New Print </p>
</Button> </div>
</a> <div class="flex gap-3">
</div> <Button
</div> variant="secondary"
size="sm"
onclick={() => (showQuickLogModal = true)}>Quick Log</Button
>
<a href="/prints">
<Button variant="primary" size="sm" class="shadow-blue-500/20">
<span class="mr-2 text-lg leading-none">+</span> New Print
</Button>
</a>
</div>
</div>
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<Card class="relative overflow-hidden group"> <Card class="relative overflow-hidden group">
<div <div
class="absolute inset-0 bg-linear-to-br from-blue-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" class="absolute inset-0 bg-linear-to-br from-blue-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div> ></div>
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider"> <p
Active Spools class="text-xs font-medium text-slate-400 uppercase tracking-wider"
</p> >
<div class="flex items-baseline mt-2"> Active Spools
<span class="text-3xl font-bold text-white">{stats.spoolCount}</span> </p>
</div> <div class="flex items-baseline mt-2">
</div> <span class="text-3xl font-bold text-white"
</Card> >{stats.spoolCount}</span
>
</div>
</div>
</Card>
<Card class="relative overflow-hidden group"> <Card class="relative overflow-hidden group">
<div <div
class="absolute inset-0 bg-linear-to-br from-violet-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" class="absolute inset-0 bg-linear-to-br from-violet-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div> ></div>
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider"> <p
Filament On Hand class="text-xs font-medium text-slate-400 uppercase tracking-wider"
</p> >
<div class="flex items-baseline mt-2"> Filament On Hand
{#if stats.totalWeightG >= 1000} </p>
<span class="text-3xl font-bold text-white" <div class="flex items-baseline mt-2">
>{stats.totalWeightKg}<span {#if stats.totalWeightG >= 1000}
class="text-sm font-normal text-slate-500 ml-1">kg</span <span class="text-3xl font-bold text-white"
></span >{stats.totalWeightKg}<span
> class="text-sm font-normal text-slate-500 ml-1"
{:else} >kg</span
<span class="text-3xl font-bold text-white" ></span
>{stats.totalWeightG}<span >
class="text-sm font-normal text-slate-500 ml-1">g</span {:else}
></span <span class="text-3xl font-bold text-white"
> >{stats.totalWeightG}<span
{/if} class="text-sm font-normal text-slate-500 ml-1"
</div> >g</span
</div> ></span
</Card> >
{/if}
</div>
</div>
</Card>
<Card class="relative overflow-hidden group"> <Card class="relative overflow-hidden group">
<div <div
class="absolute inset-0 bg-linear-to-br from-emerald-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" class="absolute inset-0 bg-linear-to-br from-emerald-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div> ></div>
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider"> <p
Printers class="text-xs font-medium text-slate-400 uppercase tracking-wider"
</p> >
<div class="flex items-baseline mt-2"> Printers
<span class="text-3xl font-bold text-white">{stats.printerCount}</span </p>
> <div class="flex items-baseline mt-2">
</div> <span class="text-3xl font-bold text-white"
</div> >{stats.printerCount}</span
</Card> >
</div>
</div>
</Card>
<Card class="relative overflow-hidden group"> <Card class="relative overflow-hidden group">
<div <div
class="absolute inset-0 bg-linear-to-br from-amber-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" class="absolute inset-0 bg-linear-to-br from-amber-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div> ></div>
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider"> <p
Est. Value class="text-xs font-medium text-slate-400 uppercase tracking-wider"
</p> >
<div class="flex items-baseline mt-2"> Est. Value
<span class="text-3xl font-bold text-white" </p>
>${stats.estimatedValue}</span <div class="flex items-baseline mt-2">
> <span class="text-3xl font-bold text-white"
</div> >${stats.estimatedValue}</span
</div> >
</Card> </div>
</div>
</Card>
<Card class="relative overflow-hidden group"> <Card class="relative overflow-hidden group">
<div <div
class="absolute inset-0 bg-linear-to-br from-rose-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" class="absolute inset-0 bg-linear-to-br from-rose-500/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
></div> ></div>
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider"> <p
Total Spent class="text-xs font-medium text-slate-400 uppercase tracking-wider"
</p> >
<div class="flex items-baseline mt-2"> Total Spent
<span class="text-3xl font-bold text-white">${stats.totalSpent}</span> </p>
</div> <div class="flex items-baseline mt-2">
</div> <span class="text-3xl font-bold text-white"
</Card> >${stats.totalSpent}</span
</div> >
</div>
</div>
</Card>
</div>
<!-- Main Content Grid --> <!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="lg:col-span-2 space-y-6"> <div class="lg:col-span-2 space-y-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-white/90">Recent Activity</h2> <h2 class="text-xl font-semibold text-white/90">
<a Recent Activity
href="/prints" </h2>
class="text-xs text-blue-400 hover:text-blue-300 transition-colors" <a
>View All</a href="/prints"
> class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
</div> >View All</a
>
</div>
{#if recentPrints.length === 0} {#if recentPrints.length === 0}
<Card <Card
class="min-h-[300px] flex items-center justify-center border-dashed border-slate-700 bg-transparent/50" class="min-h-[300px] flex items-center justify-center border-dashed border-slate-700 bg-transparent/50"
> >
<div class="text-center"> <div class="text-center">
<div <div
class="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-600" class="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-600"
> >
<Icon icon="mdi:printer-3d-nozzle" class="w-6 h-6" /> <Icon
</div> icon="mdi:printer-3d-nozzle"
<p class="text-slate-500">No recent prints found</p> class="w-6 h-6"
<a href="/prints"> />
<Button </div>
variant="ghost" <p class="text-slate-500">No recent prints found</p>
size="sm" <a href="/prints">
class="mt-2 text-blue-400 hover:text-blue-300" <Button
>Log a Print</Button variant="ghost"
> size="sm"
</a> class="mt-2 text-blue-400 hover:text-blue-300"
</div> >Log a Print</Button
</Card> >
{:else} </a>
<div class="space-y-3"> </div>
{#each recentPrints as print} </Card>
<Card {:else}
class="flex items-center justify-between p-4! hover:bg-slate-800/80 transition-colors group" <div class="space-y-3">
> {#each recentPrints as print}
<div class="flex items-center gap-4"> <Card
<div class="flex items-center justify-between p-4! hover:bg-slate-800/80 transition-colors group"
class="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center group-hover:scale-105 transition-transform >
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center group-hover:scale-105 transition-transform
{print.status === 'Fail' {print.status === 'Fail'
? 'text-red-400 bg-red-500/10' ? 'text-red-400 bg-red-500/10'
: print.status === 'In Progress' : print.status === 'In Progress'
? 'text-blue-400 bg-blue-500/10' ? 'text-blue-400 bg-blue-500/10'
: 'text-blue-400 bg-blue-500/10'}" : 'text-blue-400 bg-blue-500/10'}"
> >
{#if print.status === "Success"} {#if print.status === "Success"}
<Icon <Icon
icon="mdi:check-circle" icon="mdi:check-circle"
class="w-5 h-5 text-green-400" class="w-5 h-5 text-green-400"
/> />
{:else if print.status === "Fail"} {:else if print.status === "Fail"}
<Icon icon="mdi:close-circle" class="w-5 h-5" /> <Icon
{:else if print.status === "In Progress"} icon="mdi:close-circle"
<Icon icon="mdi:loading" class="w-5 h-5 animate-spin" /> class="w-5 h-5"
{:else} />
<Icon icon="mdi:printer-3d" class="w-5 h-5" /> {:else if print.status === "In Progress"}
{/if} <Icon
</div> icon="mdi:loading"
<div> class="w-5 h-5 animate-spin"
<h4 class="text-sm font-semibold text-white">{print.name}</h4> />
<p class="text-xs text-slate-400 mt-0.5"> {:else}
{print.printer_id?.name || "Unknown Printer"}{print.filament_used_g}g <Icon
</p> icon="mdi:printer-3d"
</div> class="w-5 h-5"
</div> />
<div class="text-right"> {/if}
<span </div>
class="px-2 py-0.5 rounded text-[10px] font-medium <div>
<h4
class="text-sm font-semibold text-white"
>
{print.name}
</h4>
<p class="text-xs text-slate-400 mt-0.5">
{print.printer_id?.name ||
"Unknown Printer"}{print.filament_used_g}g
</p>
</div>
</div>
<div class="text-right">
<span
class="px-2 py-0.5 rounded text-[10px] font-medium
{print.status === 'Success' {print.status === 'Success'
? 'bg-green-500/10 text-green-400' ? 'bg-green-500/10 text-green-400'
: print.status === 'Fail' : print.status === 'Fail'
? 'bg-red-500/10 text-red-400' ? 'bg-red-500/10 text-red-400'
: 'bg-slate-700 text-slate-400'}" : 'bg-slate-700 text-slate-400'}"
> >
{print.status} {print.status}
</span> </span>
<p class="text-xs text-slate-500 mt-1"> <p class="text-xs text-slate-500 mt-1">
{new Date(print.date).toLocaleDateString()} {new Date(print.date).toLocaleDateString(
</p> "en-US",
</div> {
</Card> timeZone: "UTC",
{/each} },
</div> )}
{/if} </p>
</div> </div>
</Card>
{/each}
</div>
{/if}
</div>
<!-- Quick Actions / Status --> <!-- Quick Actions / Status -->
<div class="space-y-6"> <div class="space-y-6">
<h2 class="text-xl font-semibold text-white/90">Printer Status</h2> <h2 class="text-xl font-semibold text-white/90">Printer Status</h2>
<Card> <Card>
{#if activePrintJob} {#if activePrintJob}
<!-- Active Print Job --> <!-- Active Print Job -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<span class="font-medium text-white" <span class="font-medium text-white"
>{activePrintJob.printer_id?.name || "Unknown Printer"}</span >{activePrintJob.printer_id?.name ||
> "Unknown Printer"}</span
<span >
class="px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-wide bg-blue-500/10 text-blue-400 border border-blue-500/20 animate-pulse" <span
>Printing</span class="px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-wide bg-blue-500/10 text-blue-400 border border-blue-500/20 animate-pulse"
> >Printing</span
</div> >
</div>
<div class="space-y-4"> <div class="space-y-4">
<!-- Job Name --> <!-- Job Name -->
<div> <div>
<p class="text-xs text-slate-400 mb-1">Currently Printing</p> <p class="text-xs text-slate-400 mb-1">
<p class="text-sm font-semibold text-white"> Currently Printing
{activePrintJob.name} </p>
</p> <p class="text-sm font-semibold text-white">
</div> {activePrintJob.name}
</p>
</div>
<!-- Progress Bar --> <!-- Progress Bar -->
<div class="space-y-1.5"> <div class="space-y-1.5">
<div <div
class="flex justify-between text-xs text-slate-400 font-medium" class="flex justify-between text-xs text-slate-400 font-medium"
> >
<span>Progress</span> <span>Progress</span>
<span class="text-slate-200" <span class="text-slate-200"
>{Math.round( >{Math.round(
getProgress( getProgress(
activePrintJob.started_at, activePrintJob.started_at,
activePrintJob.duration_minutes activePrintJob.duration_minutes,
) ),
)}%</span )}%</span
> >
</div> </div>
<div class="h-2 bg-surface-800 rounded-full overflow-hidden"> <div
<div class="h-2 bg-surface-800 rounded-full overflow-hidden"
class="h-full bg-blue-500 transition-all duration-1000" >
style="width: {getProgress( <div
activePrintJob.started_at, class="h-full bg-blue-500 transition-all duration-1000"
activePrintJob.duration_minutes style="width: {getProgress(
)}%" activePrintJob.started_at,
></div> activePrintJob.duration_minutes,
</div> )}%"
</div> ></div>
</div>
</div>
<!-- Time & Material Info --> <!-- Time & Material Info -->
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p class="text-xs text-slate-400">Time Remaining</p> <p class="text-xs text-slate-400">
<p class="font-medium text-white"> Time Remaining
{getTimeRemaining( </p>
activePrintJob.started_at, <p class="font-medium text-white">
activePrintJob.duration_minutes {getTimeRemaining(
)} activePrintJob.started_at,
</p> activePrintJob.duration_minutes,
</div> )}
<div> </p>
<p class="text-xs text-slate-400">Filament</p> </div>
<p class="font-medium text-white"> <div>
{activePrintJob.filament_used_g}g <p class="text-xs text-slate-400">Filament</p>
</p> <p class="font-medium text-white">
</div> {activePrintJob.filament_used_g}g
</div> </p>
</div>
</div>
<!-- Spool Info --> <!-- Spool Info -->
{#if activePrintJob.spool_id} {#if activePrintJob.spool_id}
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<div <div
class="w-3 h-3 rounded-full" class="w-3 h-3 rounded-full"
style="background-color: {activePrintJob.spool_id.color_hex}" style="background-color: {activePrintJob
></div> .spool_id.color_hex}"
<span class="text-slate-300" ></div>
>{activePrintJob.spool_id.brand} <span class="text-slate-300"
{activePrintJob.spool_id.material || ""}</span >{activePrintJob.spool_id.brand}
> {activePrintJob.spool_id.material ||
</div> ""}</span
{/if} >
</div> </div>
{/if}
</div>
<div class="mt-6 pt-4 border-t border-surface-700/50"> <div class="mt-6 pt-4 border-t border-surface-700/50">
<a href="/prints" class="block"> <a href="/prints" class="block">
<Button variant="secondary" size="sm" class="w-full text-xs" <Button
>View All Prints</Button variant="secondary"
> size="sm"
</a> class="w-full text-xs">View All Prints</Button
</div> >
{:else if activePrinter} </a>
<!-- Idle State --> </div>
<div class="flex items-center justify-between mb-4"> {:else if activePrinter}
<span class="font-medium text-white">{activePrinter.name}</span> <!-- Idle State -->
<span <div class="flex items-center justify-between mb-4">
class="px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-wide bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" <span class="font-medium text-white"
>Idle</span >{activePrinter.name}</span
> >
</div> <span
<div class="text-center py-4"> class="px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-wide bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
<p class="text-sm text-slate-400 mb-4">No active print job</p> >Idle</span
<a href="/prints"> >
<Button variant="primary" size="sm">Start a Print</Button> </div>
</a> <div class="text-center py-4">
</div> <p class="text-sm text-slate-400 mb-4">
<div class="mt-4 pt-4 border-t border-surface-700/50"> No active print job
<a href="/printers" class="block"> </p>
<Button variant="secondary" size="sm" class="w-full text-xs" <a href="/prints">
>Manage Printer</Button <Button variant="primary" size="sm"
> >Start a Print</Button
</a> >
</div> </a>
{:else} </div>
<div class="text-center py-6"> <div class="mt-4 pt-4 border-t border-surface-700/50">
<p class="text-sm text-text-muted mb-4">No printers configured</p> <a href="/printers" class="block">
<a href="/printers"> <Button
<Button variant="primary" size="sm">Add Printer</Button> variant="secondary"
</a> size="sm"
</div> class="w-full text-xs">Manage Printer</Button
{/if} >
</Card> </a>
</div> </div>
</div> {:else}
<div class="text-center py-6">
<p class="text-sm text-text-muted mb-4">
No printers configured
</p>
<a href="/printers">
<Button variant="primary" size="sm"
>Add Printer</Button
>
</a>
</div>
{/if}
</Card>
</div>
</div>
</div> </div>
<LogPrintModal
open={showQuickLogModal}
{printers}
{spools}
onclose={() => (showQuickLogModal = false)}
action="/prints?/log"
/>
<style> <style>
/* Simple entry animation */ /* Simple entry animation */
.fade-in { .fade-in {
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
@keyframes fadeIn { @keyframes fadeIn {
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
</style> </style>

View File

@@ -11,14 +11,14 @@ 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');
await connectDB(); await connectDB();
// Fetch prints with populated fields (Spool and Printer) - filtered by user // Fetch prints with populated fields (Spool and Printer) - filtered by user
const prints = await PrintJob.find({ user_id: locals.user.id }) const prints = await PrintJob.find({ user_id: locals.user.id })
.populate('spool_id', 'brand material color_hex') .populate('spool_id', 'brand material color_hex')
.populate('printer_id', 'name model') .populate('printer_id', 'name model')
.sort({ date: -1 }) .sort({ date: -1, _id: -1 })
.lean(); .lean();
// Fetch active spools and printers for the "Log Print" form - filtered by user // Fetch active spools and printers for the "Log Print" form - filtered by user
@@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ locals }) => {
Spool.find({ user_id: locals.user.id, is_active: true }).lean(), Spool.find({ user_id: locals.user.id, is_active: true }).lean(),
Printer.find({ user_id: locals.user.id }).lean() Printer.find({ user_id: locals.user.id }).lean()
]); ]);
return { return {
prints: JSON.parse(JSON.stringify(prints)), prints: JSON.parse(JSON.stringify(prints)),
spools: JSON.parse(JSON.stringify(spools)), spools: JSON.parse(JSON.stringify(spools)),
@@ -37,7 +37,7 @@ export const load: PageServerLoad = async ({ locals }) => {
export const actions: Actions = { export const actions: Actions = {
log: async ({ request, locals }) => { log: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true }); if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name'); const name = formData.get('name');
const spool_id = formData.get('spool_id'); const spool_id = formData.get('spool_id');
@@ -71,7 +71,7 @@ export const actions: Actions = {
const weightUsed = Number(filament_used_g); const weightUsed = Number(filament_used_g);
const durationMins = Number(duration_minutes); const durationMins = Number(duration_minutes);
// Calculate Filament Cost: use manual if provided, otherwise calculate // Calculate Filament Cost: use manual if provided, otherwise calculate
let costFilament: number; let costFilament: number;
if (manual_cost && String(manual_cost).trim() !== '') { if (manual_cost && String(manual_cost).trim() !== '') {
@@ -96,14 +96,14 @@ export const actions: Actions = {
// 2. Create Print Job // 2. Create Print Job
const isInProgress = status === 'In Progress'; const isInProgress = status === 'In Progress';
// Calculate started_at based on elapsed time // Calculate started_at based on elapsed time
let startedAt: Date | null = null; let startedAt: Date | null = null;
if (isInProgress) { if (isInProgress) {
const elapsedMs = Number(elapsed_minutes || 0) * 60 * 1000; const elapsedMs = Number(elapsed_minutes || 0) * 60 * 1000;
startedAt = new Date(Date.now() - elapsedMs); startedAt = new Date(Date.now() - elapsedMs);
} }
await PrintJob.create({ await PrintJob.create({
user_id: locals.user.id, user_id: locals.user.id,
name: name || 'Untitled Print', name: name || 'Untitled Print',
@@ -134,7 +134,7 @@ export const actions: Actions = {
edit: async ({ request, locals }) => { edit: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true }); if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData(); const formData = await request.formData();
const id = formData.get('id'); const id = formData.get('id');
const name = formData.get('name'); const name = formData.get('name');
@@ -165,17 +165,17 @@ export const actions: Actions = {
const weightUsed = Number(filament_used_g); const weightUsed = Number(filament_used_g);
const durationMins = Number(duration_minutes); const durationMins = Number(duration_minutes);
// Get printer for power calculation // Get printer for power calculation
const printerForCalc = printer_id const printerForCalc = printer_id
? await Printer.findById(printer_id) ? await Printer.findById(printer_id)
: printJob.printer_id; : printJob.printer_id;
// Get correct spool for cost calculation (use new spool if provided, else existing) // Get correct spool for cost calculation (use new spool if provided, else existing)
const spoolForCalc = spool_id const spoolForCalc = spool_id
? await Spool.findById(spool_id) ? await Spool.findById(spool_id)
: printJob.spool_id; : printJob.spool_id;
// Calculate Filament Cost: use manual if provided, otherwise calculate // Calculate Filament Cost: use manual if provided, otherwise calculate
let costFilament: number; let costFilament: number;
if (manual_cost && String(manual_cost).trim() !== '') { if (manual_cost && String(manual_cost).trim() !== '') {
@@ -203,7 +203,7 @@ export const actions: Actions = {
// Calculate started_at based on elapsed time for In Progress // Calculate started_at based on elapsed time for In Progress
const isInProgress = status === 'In Progress'; const isInProgress = status === 'In Progress';
let startedAt = printJob.started_at; let startedAt = printJob.started_at;
if (isInProgress && elapsed_minutes) { if (isInProgress && elapsed_minutes) {
const elapsedMs = Number(elapsed_minutes) * 60 * 1000; const elapsedMs = Number(elapsed_minutes) * 60 * 1000;
startedAt = new Date(Date.now() - elapsedMs); startedAt = new Date(Date.now() - elapsedMs);
@@ -271,7 +271,7 @@ export const actions: Actions = {
delete: async ({ request, locals }) => { delete: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true }); if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData(); const formData = await request.formData();
const id = formData.get('id'); const id = formData.get('id');
@@ -282,7 +282,7 @@ export const actions: Actions = {
try { try {
// Find the print first to get the model file path // Find the print first to get the model file path
const printJob = await PrintJob.findOne({ _id: id, user_id: locals.user.id }); const printJob = await PrintJob.findOne({ _id: id, user_id: locals.user.id });
if (printJob?.stl_file) { if (printJob?.stl_file) {
// Delete the model file from disk // Delete the model file from disk
const filePath = path.join('static', printJob.stl_file); const filePath = path.join('static', printJob.stl_file);
@@ -294,7 +294,7 @@ export const actions: Actions = {
} }
} }
} }
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) {
@@ -305,7 +305,7 @@ export const actions: Actions = {
duplicate: async ({ request, locals }) => { duplicate: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true }); if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData(); const formData = await request.formData();
const id = formData.get('id'); const id = formData.get('id');