Compare commits

...

9 Commits

25 changed files with 1992 additions and 911 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"]

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.

182
README.md
View File

@@ -1,20 +1,21 @@
# 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 web application designed to help 3D printing enthusiasts manage their filament inventory, track print jobs, view 3D models, and calculate costs and energy usage.
## 🛠️ Technology Stack ![Filaprint Dashboard](https://img.shields.io/badge/Filaprint-3D%20Print%20Manager-blue?style=for-the-badge)
- **Framework:** SvelteKit (Svelte 5) ## Technology Stack
- **Framework:** SvelteKit
- **Language:** TypeScript - **Language:** TypeScript
- **Styling:** Tailwind CSS v4 (Cerberus Theme) - **Styling:** Tailwind CSS
- **State Management:** Svelte 5 Runes - **3D Rendering:** Three.js (STL & OBJ loaders)
- **Build Tool:** Vite
- **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
## Features ## Features
### 1. Dashboard ### 1. Dashboard
@@ -37,23 +38,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,138 +78,87 @@ 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.
## 🗂️ Data Models (Mongoose Schemas) ## Getting Started
### User Schema
- `_id`: ObjectId
- `username`: String (Required, Unique)
- `email`: String
- `password`: String (Hashed with bcrypt)
- `role`: String (Enum: User, Admin)
- `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
- `status`: String (Enum: Success, Fail, Cancelled, In Progress)
- `started_at`: Date (For In Progress jobs)
- `date`: Date (Default: Date.now)
## 🚀 Getting Started
### Prerequisites ### Prerequisites
- 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 ### Docker Deployment
```bash ```bash
# Clone the repository # Copy environment file
git clone https://github.com/yourusername/filaprint.git
cd filaprint
# Install dependencies
bun install
# Set up environment variables
cp .env.example .env cp .env.example .env
# Edit .env with your MongoDB URI and JWT secret # Edit .env with secure values
# Run development server # Build and start containers
bun run dev 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 ## Completed Features
```
src/
├── lib/
│ ├── components/
│ │ ├── ui/ # Base UI components (Button, Card, Input, Modal)
│ │ ├── prints/ # Print-specific components (LogPrintModal, EditPrintModal)
│ │ └── Navbar.svelte
│ ├── models/ # Mongoose schemas
│ └── server/ # Server utilities (db connection, auth)
├── routes/
│ ├── admin/users/ # Admin user management
│ ├── analytics/ # Analytics dashboard
│ ├── login/ # Authentication
│ ├── printers/ # Printer management
│ ├── prints/ # Print job logging
│ ├── register/ # User registration
│ ├── settings/ # User settings
│ └── spools/ # Filament inventory
└── app.css # Global styles (Cerberus theme)
```
## ✅ 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
- [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 Features
- [ ] 3D spool visualization with Threlte
- [ ] 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 - [ ] Thumbnail generation for 3D models
- [ ] Print job templates
## License
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

@@ -6,132 +6,155 @@
*/ */
:root { :root {
/* Spacing & Typography */ /* Spacing & Typography */
--spacing: 0.25rem; --spacing: 0.25rem;
--text-scaling: 1.067; --text-scaling: 1.067;
/* Font Family - JetBrains Mono */ /* Font Family - JetBrains Mono */
--base-font-family: "JetBrains Mono", system-ui, sans-serif; --base-font-family: "JetBrains Mono", system-ui, sans-serif;
/* Primary Colors */ /* Primary Colors */
--color-primary-50: oklch(0.92 0.04 257.51); --color-primary-50: oklch(0.92 0.04 257.51);
--color-primary-100: oklch(0.84 0.08 254.62); --color-primary-100: oklch(0.84 0.08 254.62);
--color-primary-200: oklch(0.77 0.11 254.28); --color-primary-200: oklch(0.77 0.11 254.28);
--color-primary-300: oklch(0.7 0.15 254.36); --color-primary-300: oklch(0.7 0.15 254.36);
--color-primary-400: oklch(0.63 0.19 255.71); --color-primary-400: oklch(0.63 0.19 255.71);
--color-primary-500: oklch(0.57 0.21 258.29); --color-primary-500: oklch(0.57 0.21 258.29);
--color-primary-600: oklch(0.52 0.19 258.15); --color-primary-600: oklch(0.52 0.19 258.15);
--color-primary-700: oklch(0.46 0.17 257.78); --color-primary-700: oklch(0.46 0.17 257.78);
--color-primary-800: oklch(0.4 0.14 257.62); --color-primary-800: oklch(0.4 0.14 257.62);
--color-primary-900: oklch(0.34 0.11 257.14); --color-primary-900: oklch(0.34 0.11 257.14);
--color-primary-950: oklch(0.28 0.08 257.49); --color-primary-950: oklch(0.28 0.08 257.49);
/* Secondary Colors */ /* Secondary Colors */
--color-secondary-50: oklch(0.87 0.05 300.12); --color-secondary-50: oklch(0.87 0.05 300.12);
--color-secondary-100: oklch(0.79 0.09 303.55); --color-secondary-100: oklch(0.79 0.09 303.55);
--color-secondary-200: oklch(0.7 0.13 304.43); --color-secondary-200: oklch(0.7 0.13 304.43);
--color-secondary-300: oklch(0.63 0.17 303.8); --color-secondary-300: oklch(0.63 0.17 303.8);
--color-secondary-400: oklch(0.55 0.2 302.74); --color-secondary-400: oklch(0.55 0.2 302.74);
--color-secondary-500: oklch(0.49 0.23 300.45); --color-secondary-500: oklch(0.49 0.23 300.45);
--color-secondary-600: oklch(0.45 0.21 299.59); --color-secondary-600: oklch(0.45 0.21 299.59);
--color-secondary-700: oklch(0.42 0.19 298.25); --color-secondary-700: oklch(0.42 0.19 298.25);
--color-secondary-800: oklch(0.38 0.17 296.27); --color-secondary-800: oklch(0.38 0.17 296.27);
--color-secondary-900: oklch(0.34 0.15 293.96); --color-secondary-900: oklch(0.34 0.15 293.96);
--color-secondary-950: oklch(0.3 0.13 291.15); --color-secondary-950: oklch(0.3 0.13 291.15);
/* Tertiary Colors */ /* Tertiary Colors */
--color-tertiary-50: oklch(0.91 0.08 328.89); --color-tertiary-50: oklch(0.91 0.08 328.89);
--color-tertiary-100: oklch(0.83 0.13 339.66); --color-tertiary-100: oklch(0.83 0.13 339.66);
--color-tertiary-200: oklch(0.76 0.18 345.54); --color-tertiary-200: oklch(0.76 0.18 345.54);
--color-tertiary-300: oklch(0.7 0.23 350.67); --color-tertiary-300: oklch(0.7 0.23 350.67);
--color-tertiary-400: oklch(0.66 0.25 355.84); --color-tertiary-400: oklch(0.66 0.25 355.84);
--color-tertiary-500: oklch(0.65 0.26 2.47); --color-tertiary-500: oklch(0.65 0.26 2.47);
--color-tertiary-600: oklch(0.59 0.24 1.69); --color-tertiary-600: oklch(0.59 0.24 1.69);
--color-tertiary-700: oklch(0.54 0.22 0.5); --color-tertiary-700: oklch(0.54 0.22 0.5);
--color-tertiary-800: oklch(0.48 0.2 359.65); --color-tertiary-800: oklch(0.48 0.2 359.65);
--color-tertiary-900: oklch(0.43 0.17 357.7); --color-tertiary-900: oklch(0.43 0.17 357.7);
--color-tertiary-950: oklch(0.37 0.15 355.33); --color-tertiary-950: oklch(0.37 0.15 355.33);
/* Success Colors */ /* Success Colors */
--color-success-50: oklch(0.94 0.09 178.68); --color-success-50: oklch(0.94 0.09 178.68);
--color-success-500: oklch(0.83 0.13 174.96); --color-success-500: oklch(0.83 0.13 174.96);
--color-success-950: oklch(0.27 0.04 185.3); --color-success-950: oklch(0.27 0.04 185.3);
/* Warning Colors */ /* Warning Colors */
--color-warning-50: oklch(0.96 0.05 84.57); --color-warning-50: oklch(0.96 0.05 84.57);
--color-warning-500: oklch(0.82 0.14 76.72); --color-warning-500: oklch(0.82 0.14 76.72);
--color-warning-950: oklch(0.52 0.13 51.44); --color-warning-950: oklch(0.52 0.13 51.44);
/* Error Colors */ /* Error Colors */
--color-error-50: oklch(0.9 0.04 14); --color-error-50: oklch(0.9 0.04 14);
--color-error-500: oklch(0.64 0.22 28.71); --color-error-500: oklch(0.64 0.22 28.71);
--color-error-950: oklch(0.42 0.17 29.23); --color-error-950: oklch(0.42 0.17 29.23);
/* Surface Colors - Cerberus Exact */ /* Surface Colors - Cerberus Exact */
--color-surface-50: oklch(0.99 0 0); --color-surface-50: oklch(0.99 0 0);
--color-surface-100: oklch(0.91 0 0); --color-surface-100: oklch(0.91 0 0);
--color-surface-200: oklch(0.81 0 0); --color-surface-200: oklch(0.81 0 0);
--color-surface-300: oklch(0.72 0 0); --color-surface-300: oklch(0.72 0 0);
--color-surface-400: oklch(0.62 0 0); --color-surface-400: oklch(0.62 0 0);
--color-surface-500: oklch(0.51 0 0); --color-surface-500: oklch(0.51 0 0);
--color-surface-600: oklch(0.45 0 0); --color-surface-600: oklch(0.45 0 0);
--color-surface-700: oklch(0.39 0 0); --color-surface-700: oklch(0.39 0 0);
--color-surface-800: oklch(0.32 0 0); --color-surface-800: oklch(0.32 0 0);
--color-surface-900: oklch(0.25 0 0); --color-surface-900: oklch(0.25 0 0);
--color-surface-950: oklch(0.18 0 0); --color-surface-950: oklch(0.18 0 0);
/* Semantic Colors */ /* Semantic Colors */
--base-font-color: var(--color-surface-950); --base-font-color: var(--color-surface-950);
--base-font-color-dark: var(--color-surface-50); --base-font-color-dark: var(--color-surface-50);
--body-background-color: var(--color-surface-50); --body-background-color: var(--color-surface-50);
--body-background-color-dark: var(--color-surface-950); --body-background-color-dark: var(--color-surface-950);
} }
@font-face { @font-face {
font-family: "JetBrains Mono"; font-family: "JetBrains Mono";
src: url("/font/JetBrainsMono-Regular.ttf") format("truetype"); src: url("/font/JetBrainsMono-Regular.ttf") format("truetype");
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@theme { @theme {
--font-mono: "JetBrains Mono", monospace; --font-mono: "JetBrains Mono", monospace;
--font-display: "JetBrains Mono", monospace; --font-display: "JetBrains Mono", monospace;
--font-body: "JetBrains Mono", monospace; --font-body: "JetBrains Mono", monospace;
/* Register Theme Colors for Tailwind */ /* Register Theme Colors for Tailwind */
--color-background: var(--body-background-color-dark); --color-background: var(--body-background-color-dark);
--color-surface: var(--color-surface-900); --color-surface: var(--color-surface-900);
--color-primary: var(--color-primary-500); --color-primary: var(--color-primary-500);
--color-secondary: var(--color-secondary-500); --color-secondary: var(--color-secondary-500);
--color-accent: var(--color-tertiary-500); --color-accent: var(--color-tertiary-500);
--color-success: var(--color-success-500); --color-success: var(--color-success-500);
--color-warning: var(--color-warning-500); --color-warning: var(--color-warning-500);
--color-danger: var(--color-error-500); --color-danger: var(--color-error-500);
--color-text-main: var(--base-font-color-dark); --color-text-main: var(--base-font-color-dark);
--color-text-muted: var(--color-surface-400); --color-text-muted: var(--color-surface-400);
} }
body { body {
background-color: var(--body-background-color-dark); background-color: var(--body-background-color-dark);
color: var(--base-font-color-dark); color: var(--base-font-color-dark);
font-family: var(--base-font-family); font-family: var(--base-font-family);
@apply antialiased min-h-screen selection:bg-primary selection:text-white; @apply antialiased min-h-screen selection:bg-primary selection:text-white;
} }
/* Card Utilities */ /* Card Utilities */
.glass-card { .glass-card {
background-color: var(--color-surface-900); background-color: var(--color-surface-900);
border: 1px solid var(--color-surface-700); border: 1px solid var(--color-surface-700);
@apply rounded-xl shadow-lg transition-all duration-300; @apply rounded-xl shadow-lg transition-all duration-300;
} }
.glass-card:hover { .glass-card:hover {
background-color: var(--color-surface-800); background-color: var(--color-surface-800);
border-color: var(--color-primary-500); border-color: var(--color-primary-500);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5); box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
}
/* Date Input Dark Theme Styling */
input[type="date"] {
color-scheme: dark;
appearance: none;
-webkit-appearance: none;
}
input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.7);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
/* Firefox date input styling */
input[type="date"]::-moz-calendar-picker-indicator {
filter: invert(0.7);
} }

View File

@@ -1,8 +1,15 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Filaprint - 3D Printer Manager</title>
<meta
name="description"
content="Track your 3D prints, filament usage, and costs with Filaprint."
/>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

BIN
src/lib/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -47,6 +47,22 @@
window.location.reload(); window.location.reload();
} }
async function handleDuplicate() {
if (
!confirm(
"Create a duplicate print log? This will deduct filament from your spool.",
)
)
return;
isSubmitting = true;
const formData = new FormData();
formData.append("id", print._id);
await fetch("?/duplicate", { method: "POST", body: formData });
isSubmitting = false;
handleClose();
window.location.reload();
}
// STL Upload // STL Upload
let stlFile = $state<File | null>(null); let stlFile = $state<File | null>(null);
let uploadProgress = $state(0); let uploadProgress = $state(0);
@@ -101,8 +117,29 @@
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);
// 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}>
@@ -121,6 +158,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);
@@ -211,7 +253,38 @@
</div> </div>
</div> </div>
<Input label="Print Name" name="name" value={print.name} required /> <div class="grid grid-cols-2 gap-4">
<Input
label="Print Name"
name="name"
value={print.name}
required
/>
<!-- Date Field -->
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Date
</label>
<div class="relative">
<Icon
icon="mdi:calendar"
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 pointer-events-none"
/>
<input
type="date"
name="date"
value={new Date(print.date)
.toISOString()
.split("T")[0]}
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 pl-10 pr-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/30 transition-all"
/>
</div>
</div>
</div>
<!-- STL Viewer/Upload --> <!-- STL Viewer/Upload -->
<div class="space-y-2"> <div class="space-y-2">
@@ -222,22 +295,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={350}
/> height={180}
{/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 +384,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}
/> />
@@ -408,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
@@ -423,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
@@ -454,6 +558,51 @@
{:else} {:else}
<!-- Completed print fields --> <!-- Completed print fields -->
<div class="space-y-4"> <div class="space-y-4">
<!-- Printer and Spool Selection -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>Printer</label
>
<select
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"
>
{#each printers as p}
<option
value={p._id}
selected={print.printer_id?._id ===
p._id || print.printer_id === p._id}
>{p.name}</option
>
{/each}
</select>
</div>
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>Spool</label
>
<select
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"
>
{#each spools as s}
<option
value={s._id}
selected={print.spool_id?._id ===
s._id || print.spool_id === s._id}
>{s.brand}
{s.material} ({s.weight_remaining_g}g
left)</option
>
{/each}
</select>
</div>
</div>
<div class="space-y-2"> <div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control --> <!-- svelte-ignore a11y_label_has_associated_control -->
<label <label
@@ -510,22 +659,29 @@
{/if} {/if}
<div class="pt-4 flex justify-between"> <div class="pt-4 flex justify-between">
<Button <div class="flex gap-2">
variant="destructive" <Button
type="button" variant="destructive"
disabled={isSubmitting} type="button"
onclick={handleDelete} disabled={isSubmitting}
> onclick={handleDelete}
Delete
</Button>
<div class="flex gap-3">
<Button variant="ghost" onclick={handleClose} type="button"
>Cancel</Button
> >
<Button type="submit" disabled={isSubmitting}> <Icon icon="mdi:delete" class="w-4 h-4 mr-1" />
{isSubmitting ? "Saving..." : "Save Changes"} Delete
</Button>
<Button
variant="ghost"
type="button"
disabled={isSubmitting}
onclick={handleDuplicate}
>
<Icon icon="mdi:content-copy" class="w-4 h-4 mr-1" />
Duplicate
</Button> </Button>
</div> </div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div> </div>
</form> </form>
{/if} {/if}

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;
@@ -185,12 +186,36 @@
</div> </div>
</div> </div>
<Input <div class="grid grid-cols-2 gap-4">
label="Print Name" <Input
name="name" label="Print Name"
placeholder="Dragon Scale Mail" name="name"
required placeholder="Dragon Scale Mail"
/> required
/>
<!-- Date Field -->
<div class="space-y-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Date
</label>
<div class="relative">
<Icon
icon="mdi:calendar"
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500 pointer-events-none"
/>
<input
type="date"
name="date"
value={new Date().toISOString().split("T")[0]}
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 pl-10 pr-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/30 transition-all"
/>
</div>
</div>
</div>
<!-- 3D Model Upload --> <!-- 3D Model Upload -->
<div class="space-y-2"> <div class="space-y-2">
@@ -239,7 +264,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}
/> />
@@ -272,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>
@@ -287,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>
@@ -457,10 +488,7 @@
</div> </div>
{/if} {/if}
<div class="pt-4 flex justify-end gap-3"> <div class="pt-4 flex justify-end">
<Button variant="ghost" onclick={handleClose} type="button"
>Cancel</Button
>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting {isSubmitting
? "Saving..." ? "Saving..."

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

@@ -1,7 +1,7 @@
<script> <script>
import "../app.css"; import "../app.css";
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.png";
let { children } = $props(); let { children } = $props();
</script> </script>

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

@@ -1,65 +1,189 @@
import { PrintJob } from '$lib/models/PrintJob'; import { PrintJob } from '$lib/models/PrintJob';
import { Printer } from '$lib/models/Printer';
import { connectDB } from '$lib/server/db'; import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { statSync, existsSync } from 'fs';
import path from 'path';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user) throw redirect(303, '/login'); if (!locals.user) throw redirect(303, '/login');
await connectDB(); await connectDB();
// Fetch all prints for aggregation - filtered by user // Get time range from URL param (default: 30 days)
const prints = await PrintJob.find({ user_id: locals.user.id }) const range = url.searchParams.get('range') || '30';
.populate('spool_id', 'color_hex material') let dateFilter: Date | null = null;
.populate('printer_id', 'power_consumption_watts')
if (range !== 'all') {
const days = parseInt(range);
dateFilter = new Date();
dateFilter.setDate(dateFilter.getDate() - days);
}
// Build query with optional date filter
const query: any = { user_id: locals.user.id };
if (dateFilter) {
query.date = { $gte: dateFilter };
}
// Fetch all prints for aggregation
const prints = await PrintJob.find(query)
.populate('spool_id', 'color_hex material brand')
.populate('printer_id', 'power_consumption_watts name')
.sort({ date: 1 }) .sort({ date: 1 })
.lean(); .lean();
// 1. Success vs Fail // Get all printers for the user
const printers = await Printer.find({ user_id: locals.user.id }).lean();
// 1. Success vs Fail vs Cancelled
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
let cancelledCount = 0;
let inProgressCount = 0;
// 2. Material Usage (Map: Material -> Weight) // 2. Material Usage (Map: Material -> Weight)
const materialUsage: Record<string, number> = {}; const materialUsage: Record<string, number> = {};
// 3. Usage Over Time (Last 30 days) // 3. Usage Over Time
const usageByDate: Record<string, number> = {}; const usageByDate: Record<string, number> = {};
// 4. Electricity Usage Over Time (Wh) // 4. Electricity Usage Over Time (Wh)
const electricityByDate: Record<string, number> = {}; const electricityByDate: Record<string, number> = {};
let totalElectricity = 0; let totalElectricity = 0;
// 5. Cost tracking
const costByDate: Record<string, number> = {};
let totalCost = 0;
let totalFilamentCost = 0;
let totalEnergyCost = 0;
// 6. Print time tracking
let totalPrintTime = 0;
// 7. Printer usage (how many prints per printer)
const printerUsage: Record<string, { name: string; count: number; time: number }> = {};
// 8. Total filament used
let totalFilamentUsed = 0;
// 9. Prints with 3D models count
let printsWithModels = 0;
let totalModelSize = 0; // Total file size of all models in bytes
// 10. Top printed models
const modelCounts: Record<string, number> = {};
prints.forEach(print => { prints.forEach(print => {
// Status // Status
if (print.status === 'Success') successCount++; if (print.status === 'Success') successCount++;
else if (print.status === 'Fail') failCount++; else if (print.status === 'Fail') failCount++;
else if (print.status === 'Cancelled') cancelledCount++;
else if (print.status === 'In Progress') inProgressCount++;
// Material // Material
if (print.spool_id?.material) { if (print.spool_id?.material) {
const mat = print.spool_id.material; const mat = print.spool_id.material;
materialUsage[mat] = (materialUsage[mat] || 0) + print.filament_used_g; materialUsage[mat] = (materialUsage[mat] || 0) + (print.filament_used_g || 0);
} }
// Timeline - Filament // Timeline - Filament
const dateKey = new Date(print.date).toISOString().split('T')[0]; const dateKey = new Date(print.date).toISOString().split('T')[0];
usageByDate[dateKey] = (usageByDate[dateKey] || 0) + print.filament_used_g; usageByDate[dateKey] = (usageByDate[dateKey] || 0) + (print.filament_used_g || 0);
// Electricity: Power (W) × Duration (hours) = Wh // Electricity: Power (W) × Duration (hours) = Wh
const powerWatts = print.printer_id?.power_consumption_watts || 0; const powerWatts = (print.printer_id as any)?.power_consumption_watts || 0;
const durationHours = (print.duration_minutes || 0) / 60; const durationHours = (print.duration_minutes || 0) / 60;
const wattHours = powerWatts * durationHours; const wattHours = powerWatts * durationHours;
electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + wattHours; electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + wattHours;
totalElectricity += wattHours; totalElectricity += wattHours;
// Cost
const printCost = print.calculated_cost_filament || 0;
const energyCost = print.calculated_cost_energy || 0;
costByDate[dateKey] = (costByDate[dateKey] || 0) + printCost;
totalCost += printCost;
totalFilamentCost += (printCost - energyCost);
totalEnergyCost += energyCost;
// Print time
totalPrintTime += print.duration_minutes || 0;
// Printer usage
const printerId = (print.printer_id as any)?._id?.toString() || 'unknown';
const printerName = (print.printer_id as any)?.name || 'Unknown';
if (!printerUsage[printerId]) {
printerUsage[printerId] = { name: printerName, count: 0, time: 0 };
}
printerUsage[printerId].count++;
printerUsage[printerId].time += print.duration_minutes || 0;
// Filament total
totalFilamentUsed += print.filament_used_g || 0;
// 3D models
if (print.stl_file) {
printsWithModels++;
modelCounts[print.name] = (modelCounts[print.name] || 0) + 1;
// Get file size
try {
const filePath = path.join('static', print.stl_file);
if (existsSync(filePath)) {
const stats = statSync(filePath);
totalModelSize += stats.size;
}
} catch (e) {
// Ignore file read errors
}
}
}); });
// Sort top models by count
const topModels = Object.entries(modelCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
// Calculate averages
const completedPrints = successCount + failCount;
const avgPrintTime = completedPrints > 0 ? totalPrintTime / completedPrints : 0;
const avgCost = completedPrints > 0 ? totalCost / completedPrints : 0;
const avgFilament = completedPrints > 0 ? totalFilamentUsed / completedPrints : 0;
// Convert printer usage to array and sort
const printerStats = Object.values(printerUsage)
.sort((a, b) => b.count - a.count);
return { return {
analytics: { analytics: {
successRate: { success: successCount, fail: failCount }, successRate: {
success: successCount,
fail: failCount,
cancelled: cancelledCount,
inProgress: inProgressCount
},
materialUsage, materialUsage,
usageByDate, usageByDate,
electricityByDate, electricityByDate,
totalElectricity: (totalElectricity / 1000).toFixed(2) // Convert to kWh costByDate,
totalElectricity: (totalElectricity / 1000).toFixed(2),
totalCost: totalCost.toFixed(2),
totalFilamentCost: totalFilamentCost.toFixed(2),
totalEnergyCost: totalEnergyCost.toFixed(2),
totalPrintTime,
totalFilamentUsed: Math.round(totalFilamentUsed),
avgPrintTime: Math.round(avgPrintTime),
avgCost: avgCost.toFixed(2),
avgFilament: Math.round(avgFilament),
printerStats,
printsWithModels,
totalModelSize,
topModels,
totalPrints: prints.length,
range
} }
}; };
}; };

View File

@@ -1,53 +1,72 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { goto } from "$app/navigation";
import Chart from "chart.js/auto"; import Chart from "chart.js/auto";
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 Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
let { data } = $props(); let { data } = $props();
// svelte-ignore non_reactive_update
let analytics = $derived(data.analytics); let analytics = $derived(data.analytics);
// svelte-ignore non_reactive_update
let timelineCanvas: HTMLCanvasElement; let timelineCanvas: HTMLCanvasElement;
// svelte-ignore non_reactive_update
let pieCanvas: HTMLCanvasElement; let pieCanvas: HTMLCanvasElement;
// svelte-ignore non_reactive_update
let electricityCanvas: HTMLCanvasElement; let electricityCanvas: HTMLCanvasElement;
// svelte-ignore non_reactive_update
let costCanvas: HTMLCanvasElement;
// svelte-ignore non_reactive_update
let printerCanvas: HTMLCanvasElement;
onMount(() => { // Time range options
// 1. Timeline Chart - Filament Usage const ranges = [
const dates = Object.keys(analytics.usageByDate).slice(-30); { value: "7", label: "7 Days" },
const weights = dates.map((d) => analytics.usageByDate[d]); { value: "30", label: "30 Days" },
{ value: "90", label: "90 Days" },
{ value: "365", label: "1 Year" },
{ value: "all", label: "All Time" },
];
let selectedRange = $derived(analytics.range);
new Chart(timelineCanvas, { function changeRange(range: string) {
type: "line", goto(`/analytics?range=${range}`);
data: { }
labels: dates,
datasets: [ // Format minutes to hours:minutes
{ function formatTime(minutes: number): string {
label: "Filament Usage (g)", const hours = Math.floor(minutes / 60);
data: weights, const mins = minutes % 60;
borderColor: "#3b82f6", if (hours > 0) {
backgroundColor: "rgba(59, 130, 246, 0.2)", return `${hours}h ${mins}m`;
tension: 0.4, }
fill: true, return `${mins}m`;
}, }
],
}, // Format bytes to human readable
options: { function formatBytes(bytes: number): string {
responsive: true, if (bytes === 0) return "0 B";
maintainAspectRatio: false, const k = 1024;
plugins: { const sizes = ["B", "KB", "MB", "GB"];
legend: { display: false }, const i = Math.floor(Math.log(bytes) / Math.log(k));
}, return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
scales: { }
y: { grid: { color: "rgba(255,255,255,0.1)" } },
x: { grid: { display: false } }, // Chart instances
}, let timelineChart: Chart | null = null;
}, let pieChart: Chart | null = null;
}); let electricityChart: Chart | null = null;
let costChart: Chart | null = null;
let printerChart: Chart | null = null;
$effect(() => {
// Cleanup previous charts
if (timelineChart) timelineChart.destroy();
if (pieChart) pieChart.destroy();
if (electricityChart) electricityChart.destroy();
if (costChart) costChart.destroy();
if (printerChart) printerChart.destroy();
// 2. Material Pie Chart
const materials = Object.keys(analytics.materialUsage);
const matWeights = materials.map((m) => analytics.materialUsage[m]);
const chartColors = [ const chartColors = [
"#3b82f6", "#3b82f6",
"#8b5cf6", "#8b5cf6",
@@ -57,92 +76,309 @@
"#64748b", "#64748b",
]; ];
new Chart(pieCanvas, { // 1. Timeline Chart - Filament Usage
type: "doughnut", if (timelineCanvas) {
data: { const dates = Object.keys(analytics.usageByDate).slice(-30);
labels: materials, const weights = dates.map((d) => analytics.usageByDate[d]);
datasets: [
{ timelineChart = new Chart(timelineCanvas, {
data: matWeights, type: "line",
backgroundColor: chartColors, data: {
borderWidth: 0, labels: dates.map((d) =>
}, new Date(d).toLocaleDateString("en-US", {
], month: "short",
}, day: "numeric",
options: { }),
responsive: true, ),
maintainAspectRatio: false, datasets: [
plugins: { {
legend: { position: "right", labels: { color: "#94a3b8" } }, label: "Filament Usage (g)",
data: weights,
borderColor: "#3b82f6",
backgroundColor: "rgba(59, 130, 246, 0.2)",
tension: 0.4,
fill: true,
},
],
}, },
}, options: {
}); responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
grid: { color: "rgba(255,255,255,0.1)" },
ticks: { color: "#94a3b8" },
},
x: {
grid: { display: false },
ticks: { color: "#94a3b8" },
},
},
},
});
}
// 2. Material Pie Chart
if (pieCanvas) {
const materials = Object.keys(analytics.materialUsage);
const matWeights = materials.map((m) => analytics.materialUsage[m]);
pieChart = new Chart(pieCanvas, {
type: "doughnut",
data: {
labels: materials,
datasets: [
{
data: matWeights,
backgroundColor: chartColors,
borderWidth: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: { color: "#94a3b8" },
},
},
},
});
}
// 3. Electricity Usage Chart // 3. Electricity Usage Chart
const electricDates = Object.keys(analytics.electricityByDate).slice( if (electricityCanvas) {
-30, const electricDates = Object.keys(
); analytics.electricityByDate,
const electricityWh = electricDates.map( ).slice(-30);
(d) => analytics.electricityByDate[d] / 1000, const electricityWh = electricDates.map(
); // Convert to kWh (d) => analytics.electricityByDate[d] / 1000,
);
new Chart(electricityCanvas, { electricityChart = new Chart(electricityCanvas, {
type: "bar", type: "bar",
data: { data: {
labels: electricDates, labels: electricDates.map((d) =>
datasets: [ new Date(d).toLocaleDateString("en-US", {
{ month: "short",
label: "Electricity (kWh)", day: "numeric",
data: electricityWh, }),
backgroundColor: "rgba(245, 158, 11, 0.6)", ),
borderColor: "#f59e0b", datasets: [
borderWidth: 1, {
borderRadius: 4, label: "Electricity (kWh)",
}, data: electricityWh,
], backgroundColor: "rgba(245, 158, 11, 0.6)",
}, borderColor: "#f59e0b",
options: { borderWidth: 1,
responsive: true, borderRadius: 4,
maintainAspectRatio: false, },
plugins: { ],
legend: { display: false },
}, },
scales: { options: {
y: { responsive: true,
grid: { color: "rgba(255,255,255,0.1)" }, maintainAspectRatio: false,
title: { display: true, text: "kWh", color: "#94a3b8" }, plugins: {
legend: { display: false },
},
scales: {
y: {
grid: { color: "rgba(255,255,255,0.1)" },
ticks: { color: "#94a3b8" },
title: {
display: true,
text: "kWh",
color: "#94a3b8",
},
},
x: {
grid: { display: false },
ticks: { color: "#94a3b8" },
},
}, },
x: { grid: { display: false } },
}, },
}, });
}); }
// 4. Cost Chart
if (costCanvas) {
const costDates = Object.keys(analytics.costByDate).slice(-30);
const costs = costDates.map((d) => analytics.costByDate[d]);
costChart = new Chart(costCanvas, {
type: "line",
data: {
labels: costDates.map((d) =>
new Date(d).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
),
datasets: [
{
label: "Cost ($)",
data: costs,
borderColor: "#10b981",
backgroundColor: "rgba(16, 185, 129, 0.2)",
tension: 0.4,
fill: true,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
grid: { color: "rgba(255,255,255,0.1)" },
ticks: {
color: "#94a3b8",
callback: (value) => "$" + value,
},
},
x: {
grid: { display: false },
ticks: { color: "#94a3b8" },
},
},
},
});
}
// 5. Printer Usage Chart
if (printerCanvas && analytics.printerStats.length > 0) {
printerChart = new Chart(printerCanvas, {
type: "bar",
data: {
labels: analytics.printerStats.map(
(p: { name: string }) => p.name,
),
datasets: [
{
label: "Prints",
data: analytics.printerStats.map(
(p: { count: number }) => p.count,
),
backgroundColor: chartColors,
borderRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
plugins: {
legend: { display: false },
},
scales: {
y: {
grid: { display: false },
ticks: { color: "#94a3b8" },
},
x: {
grid: { color: "rgba(255,255,255,0.1)" },
ticks: { color: "#94a3b8" },
},
},
},
});
}
}); });
// Derived values
let totalPrints = $derived( let totalPrints = $derived(
analytics.successRate.success + analytics.successRate.fail, analytics.successRate.success +
analytics.successRate.fail +
analytics.successRate.cancelled,
); );
let successRate = $derived( let successRate = $derived(
totalPrints > 0 totalPrints > 0
? Math.round((analytics.successRate.success / totalPrints) * 100) ? Math.round((analytics.successRate.success / totalPrints) * 100)
: 0, : 0,
); );
// Export to CSV
function exportToCSV() {
const headers = [
"Date",
"Filament (g)",
"Electricity (kWh)",
"Cost ($)",
];
const dates = Object.keys(analytics.usageByDate);
const rows = dates.map((d) => [
d,
analytics.usageByDate[d] || 0,
((analytics.electricityByDate[d] || 0) / 1000).toFixed(3),
(analytics.costByDate[d] || 0).toFixed(2),
]);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join(
"\n",
);
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `filaprint-analytics-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
</script> </script>
<div class="space-y-6 fade-in"> <div class="space-y-6 fade-in">
<div> <div
<h1 class="text-3xl font-bold text-white">Analytics</h1> class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
<p class="text-slate-400 mt-1">Insights into your printing habits</p> >
<div>
<h1 class="text-3xl font-bold text-white">Analytics</h1>
<p class="text-slate-400 mt-1">
Insights into your printing habits
</p>
</div>
<div class="flex items-center gap-3">
<!-- Time Range Selector -->
<div class="flex bg-slate-800/50 rounded-lg p-1">
{#each ranges as range}
<button
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all {selectedRange ===
range.value
? 'bg-blue-500 text-white'
: 'text-slate-400 hover:text-white'}"
onclick={() => changeRange(range.value)}
>
{range.label}
</button>
{/each}
</div>
<!-- Export Button -->
<Button variant="ghost" onclick={exportToCSV}>
<Icon icon="mdi:download" class="w-4 h-4 mr-1" />
Export
</Button>
</div>
</div> </div>
<!-- Stats Row --> <!-- Main Stats Row -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4"> <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Card class="text-center"> <Card class="text-center">
<p <p
class="text-xs font-medium text-slate-400 uppercase tracking-wider" class="text-xs font-medium text-slate-400 uppercase tracking-wider"
> >
Total Prints Total Prints
</p> </p>
<p class="text-3xl font-bold text-white mt-1">{totalPrints}</p> <p class="text-2xl font-bold text-white mt-1">
{analytics.totalPrints}
</p>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<p <p
@@ -150,7 +386,7 @@
> >
Success Rate Success Rate
</p> </p>
<p class="text-3xl font-bold text-emerald-400 mt-1"> <p class="text-2xl font-bold text-emerald-400 mt-1">
{successRate}% {successRate}%
</p> </p>
</Card> </Card>
@@ -158,9 +394,31 @@
<p <p
class="text-xs font-medium text-slate-400 uppercase tracking-wider" class="text-xs font-medium text-slate-400 uppercase tracking-wider"
> >
Electricity Used Total Spent
</p> </p>
<p class="text-3xl font-bold text-amber-400 mt-1"> <p class="text-2xl font-bold text-green-400 mt-1">
${analytics.totalCost}
</p>
</Card>
<Card class="text-center">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Filament Used
</p>
<p class="text-2xl font-bold text-blue-400 mt-1">
{analytics.totalFilamentUsed}<span
class="text-sm font-normal text-slate-500 ml-1">g</span
>
</p>
</Card>
<Card class="text-center">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Electricity
</p>
<p class="text-2xl font-bold text-amber-400 mt-1">
{analytics.totalElectricity}<span {analytics.totalElectricity}<span
class="text-sm font-normal text-slate-500 ml-1">kWh</span class="text-sm font-normal text-slate-500 ml-1">kWh</span
> >
@@ -170,37 +428,118 @@
<p <p
class="text-xs font-medium text-slate-400 uppercase tracking-wider" class="text-xs font-medium text-slate-400 uppercase tracking-wider"
> >
Materials Print Time
</p> </p>
<p class="text-3xl font-bold text-violet-400 mt-1"> <p class="text-2xl font-bold text-violet-400 mt-1">
{Object.keys(analytics.materialUsage).length} {formatTime(analytics.totalPrintTime)}
</p> </p>
</Card> </Card>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- Averages Row -->
<div class="grid grid-cols-3 gap-4">
<Card class="flex items-center gap-4">
<div class="p-3 rounded-lg bg-blue-500/10">
<Icon icon="mdi:timer-outline" class="w-8 h-8 text-blue-400" />
</div>
<div>
<p class="text-xs text-slate-400 uppercase">Avg Print Time</p>
<p class="text-xl font-bold text-white">
{formatTime(analytics.avgPrintTime)}
</p>
</div>
</Card>
<Card class="flex items-center gap-4">
<div class="p-3 rounded-lg bg-green-500/10">
<Icon icon="mdi:currency-usd" class="w-8 h-8 text-green-400" />
</div>
<div>
<p class="text-xs text-slate-400 uppercase">Avg Cost/Print</p>
<p class="text-xl font-bold text-white">${analytics.avgCost}</p>
</div>
</Card>
<Card class="flex items-center gap-4">
<div class="p-3 rounded-lg bg-violet-500/10">
<Icon icon="mdi:scale" class="w-8 h-8 text-violet-400" />
</div>
<div>
<p class="text-xs text-slate-400 uppercase">
Avg Filament/Print
</p>
<p class="text-xl font-bold text-white">
{analytics.avgFilament}g
</p>
</div>
</Card>
</div>
<!-- Cost Breakdown -->
<Card>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<Icon icon="mdi:wallet" class="w-5 h-5 text-green-400" />
<h3 class="text-lg font-semibold text-white">Cost Breakdown</h3>
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
<p class="text-xs text-slate-400 uppercase mb-1">
Filament Cost
</p>
<p class="text-2xl font-bold text-blue-400">
${analytics.totalFilamentCost}
</p>
</div>
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
<p class="text-xs text-slate-400 uppercase mb-1">Energy Cost</p>
<p class="text-2xl font-bold text-amber-400">
${analytics.totalEnergyCost}
</p>
</div>
<div class="p-4 rounded-lg bg-slate-800/50 text-center">
<p class="text-xs text-slate-400 uppercase mb-1">Total Cost</p>
<p class="text-2xl font-bold text-green-400">
${analytics.totalCost}
</p>
</div>
</div>
</Card>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Usage Timeline --> <!-- Usage Timeline -->
<Card class="col-span-1 md:col-span-2 min-h-[350px]"> <Card class="min-h-[350px]">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<Icon icon="mdi:scale" class="w-5 h-5 text-blue-400" /> <Icon icon="mdi:chart-line" class="w-5 h-5 text-blue-400" />
<h3 class="text-lg font-semibold text-white"> <h3 class="text-lg font-semibold text-white">Filament Usage</h3>
Daily Filament Usage (g)
</h3>
</div> </div>
<div class="h-[280px]"> <div class="h-[280px]">
<canvas bind:this={timelineCanvas}></canvas> <canvas bind:this={timelineCanvas}></canvas>
</div> </div>
</Card> </Card>
<!-- Cost Timeline -->
<Card class="min-h-[350px]">
<div class="flex items-center gap-2 mb-4">
<Icon
icon="mdi:chart-areaspline"
class="w-5 h-5 text-green-400"
/>
<h3 class="text-lg font-semibold text-white">Cost Over Time</h3>
</div>
<div class="h-[280px]">
<canvas bind:this={costCanvas}></canvas>
</div>
</Card>
<!-- Electricity Usage Chart --> <!-- Electricity Usage Chart -->
<Card class="col-span-1 md:col-span-2 min-h-[350px]"> <Card class="min-h-[350px]">
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<Icon <Icon
icon="mdi:lightning-bolt" icon="mdi:lightning-bolt"
class="w-5 h-5 text-amber-400" class="w-5 h-5 text-amber-400"
/> />
<h3 class="text-lg font-semibold text-white"> <h3 class="text-lg font-semibold text-white">
Daily Electricity Usage (kWh) Electricity Usage
</h3> </h3>
</div> </div>
<div class="h-[280px]"> <div class="h-[280px]">
@@ -208,7 +547,22 @@
</div> </div>
</Card> </Card>
<!-- Success Rate Stat --> <!-- Material Distribution -->
<Card class="min-h-[350px]">
<div class="flex items-center gap-2 mb-4">
<Icon icon="mdi:chart-pie" class="w-5 h-5 text-violet-400" />
<h3 class="text-lg font-semibold text-white">
Material Distribution
</h3>
</div>
<div class="h-[280px]">
<canvas bind:this={pieCanvas}></canvas>
</div>
</Card>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Success Rate Ring -->
<Card class="flex flex-col items-center justify-center py-8"> <Card class="flex flex-col items-center justify-center py-8">
<div class="relative w-32 h-32"> <div class="relative w-32 h-32">
<svg class="w-full h-full transform -rotate-90"> <svg class="w-full h-full transform -rotate-90">
@@ -244,23 +598,84 @@
> >
</div> </div>
</div> </div>
<p class="text-slate-400 mt-4 text-sm"> <div class="flex items-center gap-4 mt-4 text-sm">
{analytics.successRate.success} Success / {analytics.successRate <span class="flex items-center gap-1 text-emerald-400">
.fail} Fail <Icon icon="mdi:check-circle" class="w-4 h-4" />
</p> {analytics.successRate.success}
</span>
<span class="flex items-center gap-1 text-red-400">
<Icon icon="mdi:close-circle" class="w-4 h-4" />
{analytics.successRate.fail}
</span>
<span class="flex items-center gap-1 text-slate-400">
<Icon icon="mdi:cancel" class="w-4 h-4" />
{analytics.successRate.cancelled}
</span>
</div>
</Card> </Card>
<!-- Material Usage Pie --> <!-- Printer Usage -->
<Card class="min-h-[280px]">
<div class="flex items-center gap-2 mb-4">
<Icon icon="mdi:printer-3d" class="w-5 h-5 text-blue-400" />
<h3 class="text-lg font-semibold text-white">Printer Usage</h3>
</div>
{#if analytics.printerStats.length > 0}
<div class="h-[200px]">
<canvas bind:this={printerCanvas}></canvas>
</div>
{:else}
<div
class="flex items-center justify-center h-[200px] text-slate-500"
>
No printer data available
</div>
{/if}
</Card>
<!-- 3D Models Stats -->
<Card> <Card>
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">
<Icon icon="mdi:chart-pie" class="w-5 h-5 text-violet-400" /> <Icon icon="mdi:cube-scan" class="w-5 h-5 text-violet-400" />
<h3 class="text-lg font-semibold text-white"> <h3 class="text-lg font-semibold text-white">3D Models</h3>
Material Distribution
</h3>
</div> </div>
<div class="h-[250px]"> <div class="grid grid-cols-2 gap-4 mb-4">
<canvas bind:this={pieCanvas}></canvas> <div class="text-center p-3 bg-slate-800/50 rounded-lg">
<p class="text-2xl font-bold text-violet-400">
{analytics.printsWithModels}
</p>
<p class="text-xs text-slate-400 uppercase">
Prints with Models
</p>
</div>
<div class="text-center p-3 bg-slate-800/50 rounded-lg">
<p class="text-2xl font-bold text-purple-400">
{formatBytes(analytics.totalModelSize)}
</p>
<p class="text-xs text-slate-400 uppercase">Storage Used</p>
</div>
</div> </div>
{#if analytics.topModels.length > 0}
<div class="space-y-2">
<p class="text-xs text-slate-400 uppercase mb-2">
Most Printed
</p>
{#each analytics.topModels as model}
<div
class="flex items-center justify-between text-sm py-1"
>
<span class="text-slate-300 truncate max-w-[150px]"
>{model.name}</span
>
<span class="text-slate-500">{model.count}x</span>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-slate-500 text-center">
No models uploaded yet
</p>
{/if}
</Card> </Card>
</div> </div>
</div> </div>

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

@@ -2,6 +2,8 @@ import { PrintJob } from '$lib/models/PrintJob';
import { connectDB } from '$lib/server/db'; import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { statSync, 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');
@@ -16,7 +18,31 @@ export const load: PageServerLoad = async ({ locals }) => {
.sort({ date: -1 }) .sort({ date: -1 })
.lean(); .lean();
// Add file sizes to each model
const modelsWithSizes = printsWithSTL.map(print => {
let fileSize = 0;
if (print.stl_file) {
try {
const filePath = path.join('static', print.stl_file);
if (existsSync(filePath)) {
const stats = statSync(filePath);
fileSize = stats.size;
}
} catch (e) {
// Ignore file read errors
}
}
return {
...print,
fileSize
};
});
// Calculate total storage
const totalStorage = modelsWithSizes.reduce((sum, m) => sum + m.fileSize, 0);
return { return {
models: JSON.parse(JSON.stringify(printsWithSTL)) models: JSON.parse(JSON.stringify(modelsWithSizes)),
totalStorage
}; };
}; };

View File

@@ -5,6 +5,7 @@
let { data } = $props(); let { data } = $props();
let models = $derived(data.models); let models = $derived(data.models);
let totalStorage = $derived(data.totalStorage);
let selectedModel = $state<any>(null); let selectedModel = $state<any>(null);
let showViewer = $state(false); let showViewer = $state(false);
@@ -24,16 +25,37 @@
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
timeZone: "UTC",
}); });
} }
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
</script> </script>
<div class="space-y-6 fade-in"> <div class="space-y-6 fade-in">
<div> <div
<h1 class="text-3xl font-bold text-white">Model Library</h1> class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
<p class="text-slate-400 mt-1"> >
Browse your 3D model collection ({models.length} models) <div>
</p> <h1 class="text-3xl font-bold text-white">Model Library</h1>
<p class="text-slate-400 mt-1">
Browse your 3D model collection ({models.length} models)
</p>
</div>
<div
class="flex items-center gap-2 px-4 py-2 bg-slate-800/50 rounded-lg"
>
<Icon icon="mdi:harddisk" class="w-5 h-5 text-violet-400" />
<span class="text-sm text-slate-300"
>{formatBytes(totalStorage)} used</span
>
</div>
</div> </div>
{#if models.length === 0} {#if models.length === 0}
@@ -129,6 +151,13 @@
Failed Failed
</span> </span>
{/if} {/if}
<!-- File size -->
<span
class="flex items-center gap-1 text-slate-500 ml-auto"
>
<Icon icon="mdi:file" class="w-3.5 h-3.5" />
{formatBytes(model.fileSize)}
</span>
</div> </div>
</div> </div>
</Card> </Card>
@@ -205,6 +234,12 @@
${selectedModel.calculated_cost_filament.toFixed(2)} ${selectedModel.calculated_cost_filament.toFixed(2)}
</div> </div>
{/if} {/if}
{#if selectedModel.fileSize}
<div class="text-slate-400">
<span class="text-slate-500">File Size:</span>
{formatBytes(selectedModel.fileSize)}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -5,17 +5,20 @@ 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');
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
@@ -23,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)),
@@ -34,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');
@@ -45,6 +48,7 @@ export const actions: Actions = {
const manual_cost = formData.get('manual_cost'); const manual_cost = formData.get('manual_cost');
const elapsed_minutes = formData.get('elapsed_minutes'); const elapsed_minutes = formData.get('elapsed_minutes');
const stl_file = formData.get('stl_file'); const stl_file = formData.get('stl_file');
const date = formData.get('date');
if (!spool_id || !printer_id || !filament_used_g) { if (!spool_id || !printer_id || !filament_used_g) {
return fail(400, { missing: true }); return fail(400, { missing: true });
@@ -67,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() !== '') {
@@ -92,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',
@@ -112,7 +116,7 @@ export const actions: Actions = {
status, status,
started_at: startedAt, started_at: startedAt,
stl_file: stl_file || null, stl_file: stl_file || null,
date: new Date() date: date ? new Date(date as string) : new Date()
}); });
// 3. Deduct Filament from Spool (only if not In Progress) // 3. Deduct Filament from Spool (only if not In Progress)
@@ -130,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');
@@ -142,6 +146,8 @@ 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');
const date = formData.get('date');
if (!id || !name) { if (!id || !name) {
return fail(400, { missing: true }); return fail(400, { missing: true });
@@ -159,19 +165,24 @@ 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)
const spoolForCalc = spool_id
? await Spool.findById(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() !== '') {
// Manual cost is the total, we'll calculate energy separately for tracking // Manual cost is the total, we'll calculate energy separately for tracking
costFilament = Number(manual_cost); costFilament = Number(manual_cost);
} else if (printJob.spool_id?.price && printJob.spool_id?.weight_initial_g) { } else if (spoolForCalc?.price && spoolForCalc?.weight_initial_g) {
costFilament = (printJob.spool_id.price / printJob.spool_id.weight_initial_g) * weightUsed; costFilament = (spoolForCalc.price / spoolForCalc.weight_initial_g) * weightUsed;
} else { } else {
costFilament = 0; costFilament = 0;
} }
@@ -192,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);
@@ -208,19 +219,42 @@ export const actions: Actions = {
calculated_cost_filament: Number(totalCost.toFixed(2)), calculated_cost_filament: Number(totalCost.toFixed(2)),
calculated_cost_energy: Number(costEnergy.toFixed(2)), calculated_cost_energy: Number(costEnergy.toFixed(2)),
status, status,
started_at: startedAt started_at: startedAt,
date: date ? new Date(date as string) : printJob.date
}; };
// Update printer/spool if provided (for In Progress) // Update printer/spool if provided
if (printer_id) { if (printer_id) {
updateData.printer_id = printer_id; updateData.printer_id = printer_id;
} }
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(
@@ -237,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');
@@ -246,7 +280,89 @@ 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 };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
duplicate: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const id = formData.get('id');
if (!id) return fail(400, { missing: true });
await connectDB();
try {
// Find the original print with populated spool and printer
const original = await PrintJob.findOne({ _id: id, user_id: locals.user.id })
.populate('spool_id')
.populate('printer_id');
if (!original) return fail(404, { notFound: true });
// Get user's electricity rate
const user = await User.findById(locals.user.id);
const electricityRate = user?.electricity_rate || 0.12;
// Recalculate costs based on current spool prices
let costFilament = 0;
let costEnergy = 0;
const spool = original.spool_id as any;
const printer = original.printer_id as any;
if (spool?.price && spool?.weight_initial_g && original.filament_used_g) {
costFilament = (spool.price / spool.weight_initial_g) * original.filament_used_g;
}
if (printer?.power_consumption_watts && original.duration_minutes) {
const powerKw = printer.power_consumption_watts / 1000;
const durationHours = original.duration_minutes / 60;
costEnergy = powerKw * durationHours * electricityRate;
}
const totalCost = costFilament + costEnergy;
// Create a new print with the same details
await PrintJob.create({
user_id: locals.user.id,
name: original.name,
printer_id: original.printer_id?._id || original.printer_id,
spool_id: original.spool_id?._id || original.spool_id,
duration_minutes: original.duration_minutes,
filament_used_g: original.filament_used_g,
calculated_cost_filament: Number(totalCost.toFixed(2)),
calculated_cost_energy: Number(costEnergy.toFixed(2)),
status: 'Success',
stl_file: original.stl_file,
date: new Date()
});
// Deduct filament from spool for the new print
if (spool && original.filament_used_g) {
spool.weight_remaining_g = Math.max(0, spool.weight_remaining_g - original.filament_used_g);
await spool.save();
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -106,9 +106,10 @@
> >
<span></span> <span></span>
<span <span
>{new Date( >{new Date(print.date).toLocaleDateString(
print.date, "en-US",
).toLocaleDateString()}</span { timeZone: "UTC" },
)}</span
> >
</div> </div>
</div> </div>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB