Compare commits
9 Commits
ee6712cc8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da5bc6520b | |||
| eabc821e61 | |||
| 54bb58e5b0 | |||
| af4b45597b | |||
| 5493144933 | |||
| 6802d3ca7f | |||
| a66e266dbc | |||
| dfb73c74da | |||
| 16be4fdeaa |
13
Dockerfile
13
Dockerfile
@@ -16,7 +16,7 @@ COPY . .
|
||||
RUN bun run build
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim AS production
|
||||
FROM oven/bun:1 AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -43,16 +43,5 @@ ENV PORT=3000
|
||||
# Expose the port
|
||||
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
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
182
README.md
@@ -1,20 +1,21 @@
|
||||
# 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
|
||||

|
||||
|
||||
- **Framework:** SvelteKit (Svelte 5)
|
||||
## Technology Stack
|
||||
|
||||
- **Framework:** SvelteKit
|
||||
- **Language:** TypeScript
|
||||
- **Styling:** Tailwind CSS v4 (Cerberus Theme)
|
||||
- **State Management:** Svelte 5 Runes
|
||||
- **Build Tool:** Vite
|
||||
- **Styling:** Tailwind CSS
|
||||
- **3D Rendering:** Three.js (STL & OBJ loaders)
|
||||
- **Data Visualization:** Chart.js
|
||||
- **Icons:** Iconify (@iconify/svelte)
|
||||
- **Database:** MongoDB with Mongoose
|
||||
- **Authentication:** JWT with bcrypt password hashing
|
||||
- **Database:** MongoDB
|
||||
- **Container:** Docker with Docker Compose
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
### 1. Dashboard
|
||||
|
||||
@@ -37,23 +38,39 @@ Filaprint is a modern, premium web application designed to help 3D printing enth
|
||||
|
||||
- **Log Prints:**
|
||||
- Link to specific Printer and Filament Spool.
|
||||
- Duration (minutes) and Weight used (g).
|
||||
- Calculated Cost (auto-calculated or manual override).
|
||||
- Duration input with hours and minutes fields.
|
||||
- Weight used (g) and calculated cost (auto or manual).
|
||||
- Status: Success, Fail, Cancelled, **In Progress**.
|
||||
- **3D Model Upload:** Attach STL or OBJ files to prints.
|
||||
- **In Progress Tracking:**
|
||||
- Assign printer and spool to active jobs.
|
||||
- Specify elapsed time for accurate dashboard countdown.
|
||||
- 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.
|
||||
- **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.
|
||||
- **Specs:** Model name, Power consumption (Watts), Nozzle diameter (mm).
|
||||
- **Configure Button:** Edit or delete printer profiles.
|
||||
|
||||
### 5. Analytics
|
||||
### 6. Analytics
|
||||
|
||||
- **Daily Filament Usage:** Line chart showing filament consumption over time.
|
||||
- **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.
|
||||
- **Stats Summary:** Total prints, success rate, total electricity used.
|
||||
|
||||
### 6. User Management
|
||||
### 7. User Management
|
||||
|
||||
- **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).
|
||||
- **Role-Based Access:** Admin and User roles with appropriate permissions.
|
||||
|
||||
## 🗂️ Data Models (Mongoose Schemas)
|
||||
|
||||
### 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
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ or Bun
|
||||
- MongoDB instance (local or Atlas)
|
||||
- Docker (optional, for containerized deployment)
|
||||
|
||||
### Installation
|
||||
### Docker Deployment
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/filaprint.git
|
||||
cd filaprint
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Set up environment variables
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
# Edit .env with your MongoDB URI and JWT secret
|
||||
# Edit .env with secure values
|
||||
|
||||
# Run development server
|
||||
bun run dev
|
||||
# Build and start containers
|
||||
docker compose up -d --build
|
||||
|
||||
# View logs
|
||||
docker compose logs -f filaprint
|
||||
|
||||
# Stop containers
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# MongoDB Connection
|
||||
MONGODB_URI=mongodb://localhost:27017/filaprint
|
||||
|
||||
# JWT Secret (use a secure random string in production)
|
||||
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
|
||||
|
||||
```
|
||||
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
|
||||
## Completed Features
|
||||
|
||||
- [x] User authentication (Login/Register)
|
||||
- [x] Dashboard with live stats and active print tracking
|
||||
- [x] Spool management (CRUD)
|
||||
- [x] Printer management (CRUD)
|
||||
- [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] 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] Browser notifications for completed prints
|
||||
- [x] Iconify icon library integration
|
||||
- [x] Responsive design
|
||||
- [x] Docker containerization
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
## Future Features
|
||||
|
||||
- [ ] 3D spool visualization with Threlte
|
||||
- [ ] QR/Barcode scanning for quick spool lookup
|
||||
- [ ] Photo uploads for print jobs
|
||||
- [ ] Export data (CSV/PDF reports)
|
||||
- [ ] Multi-language support
|
||||
- [ ] Dark/Light theme toggle
|
||||
- [ ] Email notifications
|
||||
- [ ] Print job templates
|
||||
- [ ] Some notifications
|
||||
- [ ] Thumbnail generation for 3D models
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details.
|
||||
|
||||
62
bun.lock
62
bun.lock
@@ -5,10 +5,12 @@
|
||||
"": {
|
||||
"name": "filaprint",
|
||||
"dependencies": {
|
||||
"@iconify/svelte": "^5.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-node": "^5.5.1",
|
||||
"@threlte/core": "^8.3.1",
|
||||
"@threlte/extras": "^9.7.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/three": "^0.182.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -17,24 +19,24 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "^9.0.2",
|
||||
"mongoose": "^9.1.4",
|
||||
"three": "^0.182.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"svelte": "^5.46.4",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -223,18 +225,38 @@
|
||||
|
||||
"@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/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/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/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/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/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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -479,6 +501,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -599,7 +623,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
30
package.json
30
package.json
@@ -1,34 +1,36 @@
|
||||
{
|
||||
"name": "filaprint",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"start": "node server/server.js"
|
||||
"start": "bun server/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"svelte": "^5.46.4",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/svelte": "^5.1.0",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@iconify/svelte": "^5.2.1",
|
||||
"@sveltejs/adapter-node": "^5.5.1",
|
||||
"@threlte/core": "^8.3.1",
|
||||
"@threlte/extras": "^9.7.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/three": "^0.182.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -37,7 +39,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mongoose": "^9.0.2",
|
||||
"mongoose": "^9.1.4",
|
||||
"three": "^0.182.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import dotenv from 'dotenv';
|
||||
|
||||
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 {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log('MongoDB connected successfully');
|
||||
@@ -1,14 +1,15 @@
|
||||
// @ts-ignore
|
||||
import { handler } from '../build/handler.js';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { connectDB } from './db.js';
|
||||
import { connectDB } from './db';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const server = http.Server(app);
|
||||
const server = http.createServer(app);
|
||||
|
||||
app.use(cors());
|
||||
|
||||
221
src/app.css
221
src/app.css
@@ -6,132 +6,155 @@
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Spacing & Typography */
|
||||
--spacing: 0.25rem;
|
||||
--text-scaling: 1.067;
|
||||
/* Spacing & Typography */
|
||||
--spacing: 0.25rem;
|
||||
--text-scaling: 1.067;
|
||||
|
||||
/* Font Family - JetBrains Mono */
|
||||
--base-font-family: "JetBrains Mono", system-ui, sans-serif;
|
||||
/* Font Family - JetBrains Mono */
|
||||
--base-font-family: "JetBrains Mono", system-ui, sans-serif;
|
||||
|
||||
/* Primary Colors */
|
||||
--color-primary-50: oklch(0.92 0.04 257.51);
|
||||
--color-primary-100: oklch(0.84 0.08 254.62);
|
||||
--color-primary-200: oklch(0.77 0.11 254.28);
|
||||
--color-primary-300: oklch(0.7 0.15 254.36);
|
||||
--color-primary-400: oklch(0.63 0.19 255.71);
|
||||
--color-primary-500: oklch(0.57 0.21 258.29);
|
||||
--color-primary-600: oklch(0.52 0.19 258.15);
|
||||
--color-primary-700: oklch(0.46 0.17 257.78);
|
||||
--color-primary-800: oklch(0.4 0.14 257.62);
|
||||
--color-primary-900: oklch(0.34 0.11 257.14);
|
||||
--color-primary-950: oklch(0.28 0.08 257.49);
|
||||
/* Primary Colors */
|
||||
--color-primary-50: oklch(0.92 0.04 257.51);
|
||||
--color-primary-100: oklch(0.84 0.08 254.62);
|
||||
--color-primary-200: oklch(0.77 0.11 254.28);
|
||||
--color-primary-300: oklch(0.7 0.15 254.36);
|
||||
--color-primary-400: oklch(0.63 0.19 255.71);
|
||||
--color-primary-500: oklch(0.57 0.21 258.29);
|
||||
--color-primary-600: oklch(0.52 0.19 258.15);
|
||||
--color-primary-700: oklch(0.46 0.17 257.78);
|
||||
--color-primary-800: oklch(0.4 0.14 257.62);
|
||||
--color-primary-900: oklch(0.34 0.11 257.14);
|
||||
--color-primary-950: oklch(0.28 0.08 257.49);
|
||||
|
||||
/* Secondary Colors */
|
||||
--color-secondary-50: oklch(0.87 0.05 300.12);
|
||||
--color-secondary-100: oklch(0.79 0.09 303.55);
|
||||
--color-secondary-200: oklch(0.7 0.13 304.43);
|
||||
--color-secondary-300: oklch(0.63 0.17 303.8);
|
||||
--color-secondary-400: oklch(0.55 0.2 302.74);
|
||||
--color-secondary-500: oklch(0.49 0.23 300.45);
|
||||
--color-secondary-600: oklch(0.45 0.21 299.59);
|
||||
--color-secondary-700: oklch(0.42 0.19 298.25);
|
||||
--color-secondary-800: oklch(0.38 0.17 296.27);
|
||||
--color-secondary-900: oklch(0.34 0.15 293.96);
|
||||
--color-secondary-950: oklch(0.3 0.13 291.15);
|
||||
/* Secondary Colors */
|
||||
--color-secondary-50: oklch(0.87 0.05 300.12);
|
||||
--color-secondary-100: oklch(0.79 0.09 303.55);
|
||||
--color-secondary-200: oklch(0.7 0.13 304.43);
|
||||
--color-secondary-300: oklch(0.63 0.17 303.8);
|
||||
--color-secondary-400: oklch(0.55 0.2 302.74);
|
||||
--color-secondary-500: oklch(0.49 0.23 300.45);
|
||||
--color-secondary-600: oklch(0.45 0.21 299.59);
|
||||
--color-secondary-700: oklch(0.42 0.19 298.25);
|
||||
--color-secondary-800: oklch(0.38 0.17 296.27);
|
||||
--color-secondary-900: oklch(0.34 0.15 293.96);
|
||||
--color-secondary-950: oklch(0.3 0.13 291.15);
|
||||
|
||||
/* Tertiary Colors */
|
||||
--color-tertiary-50: oklch(0.91 0.08 328.89);
|
||||
--color-tertiary-100: oklch(0.83 0.13 339.66);
|
||||
--color-tertiary-200: oklch(0.76 0.18 345.54);
|
||||
--color-tertiary-300: oklch(0.7 0.23 350.67);
|
||||
--color-tertiary-400: oklch(0.66 0.25 355.84);
|
||||
--color-tertiary-500: oklch(0.65 0.26 2.47);
|
||||
--color-tertiary-600: oklch(0.59 0.24 1.69);
|
||||
--color-tertiary-700: oklch(0.54 0.22 0.5);
|
||||
--color-tertiary-800: oklch(0.48 0.2 359.65);
|
||||
--color-tertiary-900: oklch(0.43 0.17 357.7);
|
||||
--color-tertiary-950: oklch(0.37 0.15 355.33);
|
||||
/* Tertiary Colors */
|
||||
--color-tertiary-50: oklch(0.91 0.08 328.89);
|
||||
--color-tertiary-100: oklch(0.83 0.13 339.66);
|
||||
--color-tertiary-200: oklch(0.76 0.18 345.54);
|
||||
--color-tertiary-300: oklch(0.7 0.23 350.67);
|
||||
--color-tertiary-400: oklch(0.66 0.25 355.84);
|
||||
--color-tertiary-500: oklch(0.65 0.26 2.47);
|
||||
--color-tertiary-600: oklch(0.59 0.24 1.69);
|
||||
--color-tertiary-700: oklch(0.54 0.22 0.5);
|
||||
--color-tertiary-800: oklch(0.48 0.2 359.65);
|
||||
--color-tertiary-900: oklch(0.43 0.17 357.7);
|
||||
--color-tertiary-950: oklch(0.37 0.15 355.33);
|
||||
|
||||
/* Success Colors */
|
||||
--color-success-50: oklch(0.94 0.09 178.68);
|
||||
--color-success-500: oklch(0.83 0.13 174.96);
|
||||
--color-success-950: oklch(0.27 0.04 185.3);
|
||||
/* Success Colors */
|
||||
--color-success-50: oklch(0.94 0.09 178.68);
|
||||
--color-success-500: oklch(0.83 0.13 174.96);
|
||||
--color-success-950: oklch(0.27 0.04 185.3);
|
||||
|
||||
/* Warning Colors */
|
||||
--color-warning-50: oklch(0.96 0.05 84.57);
|
||||
--color-warning-500: oklch(0.82 0.14 76.72);
|
||||
--color-warning-950: oklch(0.52 0.13 51.44);
|
||||
/* Warning Colors */
|
||||
--color-warning-50: oklch(0.96 0.05 84.57);
|
||||
--color-warning-500: oklch(0.82 0.14 76.72);
|
||||
--color-warning-950: oklch(0.52 0.13 51.44);
|
||||
|
||||
/* Error Colors */
|
||||
--color-error-50: oklch(0.9 0.04 14);
|
||||
--color-error-500: oklch(0.64 0.22 28.71);
|
||||
--color-error-950: oklch(0.42 0.17 29.23);
|
||||
/* Error Colors */
|
||||
--color-error-50: oklch(0.9 0.04 14);
|
||||
--color-error-500: oklch(0.64 0.22 28.71);
|
||||
--color-error-950: oklch(0.42 0.17 29.23);
|
||||
|
||||
/* Surface Colors - Cerberus Exact */
|
||||
--color-surface-50: oklch(0.99 0 0);
|
||||
--color-surface-100: oklch(0.91 0 0);
|
||||
--color-surface-200: oklch(0.81 0 0);
|
||||
--color-surface-300: oklch(0.72 0 0);
|
||||
--color-surface-400: oklch(0.62 0 0);
|
||||
--color-surface-500: oklch(0.51 0 0);
|
||||
--color-surface-600: oklch(0.45 0 0);
|
||||
--color-surface-700: oklch(0.39 0 0);
|
||||
--color-surface-800: oklch(0.32 0 0);
|
||||
--color-surface-900: oklch(0.25 0 0);
|
||||
--color-surface-950: oklch(0.18 0 0);
|
||||
/* Surface Colors - Cerberus Exact */
|
||||
--color-surface-50: oklch(0.99 0 0);
|
||||
--color-surface-100: oklch(0.91 0 0);
|
||||
--color-surface-200: oklch(0.81 0 0);
|
||||
--color-surface-300: oklch(0.72 0 0);
|
||||
--color-surface-400: oklch(0.62 0 0);
|
||||
--color-surface-500: oklch(0.51 0 0);
|
||||
--color-surface-600: oklch(0.45 0 0);
|
||||
--color-surface-700: oklch(0.39 0 0);
|
||||
--color-surface-800: oklch(0.32 0 0);
|
||||
--color-surface-900: oklch(0.25 0 0);
|
||||
--color-surface-950: oklch(0.18 0 0);
|
||||
|
||||
/* Semantic Colors */
|
||||
--base-font-color: var(--color-surface-950);
|
||||
--base-font-color-dark: var(--color-surface-50);
|
||||
--body-background-color: var(--color-surface-50);
|
||||
--body-background-color-dark: var(--color-surface-950);
|
||||
/* Semantic Colors */
|
||||
--base-font-color: var(--color-surface-950);
|
||||
--base-font-color-dark: var(--color-surface-50);
|
||||
--body-background-color: var(--color-surface-50);
|
||||
--body-background-color-dark: var(--color-surface-950);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/font/JetBrainsMono-Regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/font/JetBrainsMono-Regular.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--font-display: "JetBrains Mono", monospace;
|
||||
--font-body: "JetBrains Mono", monospace;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--font-display: "JetBrains Mono", monospace;
|
||||
--font-body: "JetBrains Mono", monospace;
|
||||
|
||||
/* Register Theme Colors for Tailwind */
|
||||
--color-background: var(--body-background-color-dark);
|
||||
--color-surface: var(--color-surface-900);
|
||||
/* Register Theme Colors for Tailwind */
|
||||
--color-background: var(--body-background-color-dark);
|
||||
--color-surface: var(--color-surface-900);
|
||||
|
||||
--color-primary: var(--color-primary-500);
|
||||
--color-secondary: var(--color-secondary-500);
|
||||
--color-accent: var(--color-tertiary-500);
|
||||
--color-primary: var(--color-primary-500);
|
||||
--color-secondary: var(--color-secondary-500);
|
||||
--color-accent: var(--color-tertiary-500);
|
||||
|
||||
--color-success: var(--color-success-500);
|
||||
--color-warning: var(--color-warning-500);
|
||||
--color-danger: var(--color-error-500);
|
||||
--color-success: var(--color-success-500);
|
||||
--color-warning: var(--color-warning-500);
|
||||
--color-danger: var(--color-error-500);
|
||||
|
||||
--color-text-main: var(--base-font-color-dark);
|
||||
--color-text-muted: var(--color-surface-400);
|
||||
--color-text-main: var(--base-font-color-dark);
|
||||
--color-text-muted: var(--color-surface-400);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--body-background-color-dark);
|
||||
color: var(--base-font-color-dark);
|
||||
font-family: var(--base-font-family);
|
||||
@apply antialiased min-h-screen selection:bg-primary selection:text-white;
|
||||
background-color: var(--body-background-color-dark);
|
||||
color: var(--base-font-color-dark);
|
||||
font-family: var(--base-font-family);
|
||||
@apply antialiased min-h-screen selection:bg-primary selection:text-white;
|
||||
}
|
||||
|
||||
/* Card Utilities */
|
||||
.glass-card {
|
||||
background-color: var(--color-surface-900);
|
||||
border: 1px solid var(--color-surface-700);
|
||||
@apply rounded-xl shadow-lg transition-all duration-300;
|
||||
background-color: var(--color-surface-900);
|
||||
border: 1px solid var(--color-surface-700);
|
||||
@apply rounded-xl shadow-lg transition-all duration-300;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background-color: var(--color-surface-800);
|
||||
border-color: var(--color-primary-500);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5);
|
||||
background-color: var(--color-surface-800);
|
||||
border-color: var(--color-primary-500);
|
||||
transform: translateY(-2px);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Filaprint - 3D Printer Manager</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your 3D prints, filament usage, and costs with Filaprint."
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
BIN
src/lib/assets/favicon.png
Normal file
BIN
src/lib/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -3,6 +3,7 @@
|
||||
import * as THREE from "three";
|
||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.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";
|
||||
|
||||
interface Props {
|
||||
@@ -95,6 +96,8 @@
|
||||
|
||||
if (extension === "obj") {
|
||||
loadOBJ();
|
||||
} else if (extension === "gltf" || extension === "glb") {
|
||||
loadGLTF();
|
||||
} else {
|
||||
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) {
|
||||
// Center the geometry
|
||||
geometry.computeBoundingBox();
|
||||
|
||||
@@ -47,6 +47,22 @@
|
||||
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
|
||||
let stlFile = $state<File | null>(null);
|
||||
let uploadProgress = $state(0);
|
||||
@@ -101,8 +117,29 @@
|
||||
stlFile = input.files[0];
|
||||
uploadStatus = stlFile.name;
|
||||
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>
|
||||
|
||||
<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
|
||||
const hours = Number(formData.get("duration_hours") || 0);
|
||||
const mins = Number(formData.get("duration_mins") || 0);
|
||||
@@ -211,7 +253,38 @@
|
||||
</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 -->
|
||||
<div class="space-y-2">
|
||||
@@ -222,22 +295,53 @@
|
||||
3D Model {print.stl_file ? "" : "(Optional)"}
|
||||
</label>
|
||||
|
||||
{#if print.stl_file && browser && !stlFile}
|
||||
{#if print.stl_file && browser && !stlFile && !removeModel}
|
||||
<!-- Show existing STL viewer -->
|
||||
<div
|
||||
class="flex justify-center bg-slate-900 rounded-lg p-2"
|
||||
>
|
||||
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
|
||||
<STLViewer
|
||||
modelPath={print.stl_file}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
{/await}
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex justify-center bg-slate-900 rounded-lg p-2"
|
||||
>
|
||||
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
|
||||
<STLViewer
|
||||
modelPath={print.stl_file}
|
||||
width={350}
|
||||
height={180}
|
||||
/>
|
||||
{/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>
|
||||
<p class="text-xs text-slate-500 text-center">
|
||||
Click below to replace with a new model
|
||||
</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}
|
||||
|
||||
<!-- Upload button or progress -->
|
||||
@@ -280,7 +384,7 @@
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".stl,.obj"
|
||||
accept=".stl,.obj,.gltf,.glb"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
@@ -408,7 +512,7 @@
|
||||
name="elapsed_hours"
|
||||
placeholder="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"
|
||||
/>
|
||||
<span
|
||||
@@ -423,7 +527,7 @@
|
||||
placeholder="0"
|
||||
min="0"
|
||||
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"
|
||||
/>
|
||||
<span
|
||||
@@ -454,6 +558,51 @@
|
||||
{:else}
|
||||
<!-- Completed print fields -->
|
||||
<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">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label
|
||||
@@ -510,22 +659,29 @@
|
||||
{/if}
|
||||
|
||||
<div class="pt-4 flex justify-between">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} type="button"
|
||||
>Cancel</Button
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
<Icon icon="mdi:delete" class="w-4 h-4 mr-1" />
|
||||
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>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
printers: any[];
|
||||
spools: any[];
|
||||
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 selectedStatus = $state("Success");
|
||||
let stlFile = $state<File | null>(null);
|
||||
@@ -85,7 +86,7 @@
|
||||
<Modal title="Log a Print" {open} onclose={handleClose}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/log"
|
||||
{action}
|
||||
use:enhance={async ({ formData }) => {
|
||||
isSubmitting = true;
|
||||
|
||||
@@ -185,12 +186,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Print Name"
|
||||
name="name"
|
||||
placeholder="Dragon Scale Mail"
|
||||
required
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Print Name"
|
||||
name="name"
|
||||
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 -->
|
||||
<div class="space-y-2">
|
||||
@@ -239,7 +264,7 @@
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".stl,.obj"
|
||||
accept=".stl,.obj,.gltf,.glb"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
@@ -272,8 +297,11 @@
|
||||
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"
|
||||
>
|
||||
<option value="" disabled selected>Select a printer</option>
|
||||
{#each printers as p}
|
||||
<option value={p._id}>{p.name}</option>
|
||||
{:else}
|
||||
<option value="" disabled>No printers found</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -287,11 +315,14 @@
|
||||
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"
|
||||
>
|
||||
<option value="" disabled selected>Select a spool</option>
|
||||
{#each spools as s}
|
||||
<option value={s._id}
|
||||
>{s.brand}
|
||||
{s.material} ({s.weight_remaining_g}g left)</option
|
||||
>
|
||||
{:else}
|
||||
<option value="" disabled>No spools found</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -457,10 +488,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<Button variant="ghost" onclick={handleClose} type="button"
|
||||
>Cancel</Button
|
||||
>
|
||||
<div class="pt-4 flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import type { Snippet } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import type { Snippet } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: Snippet;
|
||||
onclose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: Snippet;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open, title, children, onclose }: Props = $props();
|
||||
let { open, title, children, onclose }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/70" onclick={onclose}></div>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/70" onclick={onclose}></div>
|
||||
|
||||
<!-- Dialog Panel -->
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-xl border border-[#3f3f46] shadow-2xl"
|
||||
style="background-color: #18181b;"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-[#3f3f46] px-6 py-4"
|
||||
style="background-color: #27272a;"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white">{title}</h3>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded p-1 text-[#a1a1aa] transition-colors hover:bg-[#3f3f46] hover:text-white"
|
||||
aria-label="Close"
|
||||
type="button"
|
||||
>
|
||||
<Icon icon="mdi:close" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dialog Panel -->
|
||||
<div
|
||||
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;"
|
||||
transition:scale={{ duration: 150, start: 0.95 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-[#3f3f46] px-6 py-4 shrink-0"
|
||||
style="background-color: #27272a;"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white">{title}</h3>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="rounded p-1 text-[#a1a1aa] transition-colors hover:bg-[#3f3f46] hover:text-white"
|
||||
aria-label="Close"
|
||||
type="button"
|
||||
>
|
||||
<Icon icon="mdi:close" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-6 overflow-y-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import favicon from "$lib/assets/favicon.svg";
|
||||
import favicon from "$lib/assets/favicon.png";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
@@ -7,18 +7,19 @@ import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) throw redirect(303, '/login');
|
||||
|
||||
|
||||
await connectDB();
|
||||
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
|
||||
// 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.find({ user_id: userId, is_active: true }).lean(),
|
||||
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.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.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 => {
|
||||
totalWeightG += (spool.weight_remaining_g || 0);
|
||||
|
||||
|
||||
// Value = (Remaining / Initial) * Price
|
||||
if (spool.weight_initial_g > 0 && spool.price > 0) {
|
||||
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
|
||||
const totalWeightKg = totalWeightG >= 1000
|
||||
? (totalWeightG / 1000).toFixed(2)
|
||||
const totalWeightKg = totalWeightG >= 1000
|
||||
? (totalWeightG / 1000).toFixed(2)
|
||||
: (totalWeightG / 1000).toFixed(3);
|
||||
const estimatedValue = totalValue.toFixed(2);
|
||||
|
||||
@@ -60,6 +61,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
},
|
||||
recentPrints: JSON.parse(JSON.stringify(recentPrints)),
|
||||
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))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,426 +1,517 @@
|
||||
<script lang="ts">
|
||||
import Card from "$lib/components/ui/Card.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import Card from "$lib/components/ui/Card.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
let { data } = $props();
|
||||
// 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);
|
||||
import LogPrintModal from "$lib/components/prints/LogPrintModal.svelte";
|
||||
|
||||
// Timer state
|
||||
let currentTime = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval>;
|
||||
let notificationSent = $state(false);
|
||||
let { data } = $props();
|
||||
// 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);
|
||||
// svelte-ignore non_reactive_update
|
||||
let spools = $derived(data.spools || []);
|
||||
// svelte-ignore non_reactive_update
|
||||
let printers = $derived(data.printers || []);
|
||||
|
||||
onMount(() => {
|
||||
// Request notification permission
|
||||
if (
|
||||
browser &&
|
||||
"Notification" in window &&
|
||||
Notification.permission === "default"
|
||||
) {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
let showQuickLogModal = $state(false);
|
||||
|
||||
// Update timer every 10 seconds
|
||||
timerInterval = setInterval(() => {
|
||||
currentTime = Date.now();
|
||||
// Timer state
|
||||
let currentTime = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval>;
|
||||
let notificationSent = $state(false);
|
||||
|
||||
// Check if print is complete
|
||||
if (activePrintJob && !notificationSent) {
|
||||
const progress = getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes
|
||||
);
|
||||
if (progress >= 100) {
|
||||
sendNotification();
|
||||
notificationSent = true;
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
// ... (rest of onMount/onDestroy/notifications - keeping as is)
|
||||
onMount(() => {
|
||||
// Request notification permission
|
||||
if (
|
||||
browser &&
|
||||
"Notification" in window &&
|
||||
Notification.permission === "default"
|
||||
) {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
});
|
||||
// Update timer every 10 seconds
|
||||
timerInterval = setInterval(() => {
|
||||
currentTime = Date.now();
|
||||
|
||||
function sendNotification() {
|
||||
if (
|
||||
browser &&
|
||||
"Notification" in window &&
|
||||
Notification.permission === "granted"
|
||||
) {
|
||||
new Notification("🎉 Print Complete!", {
|
||||
body: `Your print "${activePrintJob?.name}" has finished!`,
|
||||
icon: "/favicon.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Check if print is complete
|
||||
if (activePrintJob && !notificationSent) {
|
||||
const progress = getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes,
|
||||
);
|
||||
if (progress >= 100) {
|
||||
sendNotification();
|
||||
notificationSent = true;
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// 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`;
|
||||
}
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
function sendNotification() {
|
||||
if (
|
||||
browser &&
|
||||
"Notification" in window &&
|
||||
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>
|
||||
|
||||
<div class="space-y-8 fade-in">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<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 class="flex gap-3">
|
||||
<Button variant="secondary" size="sm">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>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<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 class="flex gap-3">
|
||||
<Button
|
||||
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 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Active Spools
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white">{stats.spoolCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Active Spools
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.spoolCount}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Filament On Hand
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
{#if stats.totalWeightG >= 1000}
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.totalWeightKg}<span
|
||||
class="text-sm font-normal text-slate-500 ml-1">kg</span
|
||||
></span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.totalWeightG}<span
|
||||
class="text-sm font-normal text-slate-500 ml-1">g</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Filament On Hand
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
{#if stats.totalWeightG >= 1000}
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.totalWeightKg}<span
|
||||
class="text-sm font-normal text-slate-500 ml-1"
|
||||
>kg</span
|
||||
></span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.totalWeightG}<span
|
||||
class="text-sm font-normal text-slate-500 ml-1"
|
||||
>g</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Printers
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white">{stats.printerCount}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Printers
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>{stats.printerCount}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Est. Value
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>${stats.estimatedValue}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Est. Value
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>${stats.estimatedValue}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Total Spent
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white">${stats.totalSpent}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card class="relative overflow-hidden group">
|
||||
<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"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Total Spent
|
||||
</p>
|
||||
<div class="flex items-baseline mt-2">
|
||||
<span class="text-3xl font-bold text-white"
|
||||
>${stats.totalSpent}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white/90">Recent Activity</h2>
|
||||
<a
|
||||
href="/prints"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>View All</a
|
||||
>
|
||||
</div>
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recent Activity -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white/90">
|
||||
Recent Activity
|
||||
</h2>
|
||||
<a
|
||||
href="/prints"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>View All</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if recentPrints.length === 0}
|
||||
<Card
|
||||
class="min-h-[300px] flex items-center justify-center border-dashed border-slate-700 bg-transparent/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
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" />
|
||||
</div>
|
||||
<p class="text-slate-500">No recent prints found</p>
|
||||
<a href="/prints">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mt-2 text-blue-400 hover:text-blue-300"
|
||||
>Log a Print</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each recentPrints as print}
|
||||
<Card
|
||||
class="flex items-center justify-between p-4! hover:bg-slate-800/80 transition-colors group"
|
||||
>
|
||||
<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
|
||||
{#if recentPrints.length === 0}
|
||||
<Card
|
||||
class="min-h-[300px] flex items-center justify-center border-dashed border-slate-700 bg-transparent/50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-slate-500">No recent prints found</p>
|
||||
<a href="/prints">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mt-2 text-blue-400 hover:text-blue-300"
|
||||
>Log a Print</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each recentPrints as print}
|
||||
<Card
|
||||
class="flex items-center justify-between p-4! hover:bg-slate-800/80 transition-colors group"
|
||||
>
|
||||
<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'
|
||||
? 'text-red-400 bg-red-500/10'
|
||||
: print.status === 'In Progress'
|
||||
? 'text-blue-400 bg-blue-500/10'
|
||||
: 'text-blue-400 bg-blue-500/10'}"
|
||||
>
|
||||
{#if print.status === "Success"}
|
||||
<Icon
|
||||
icon="mdi:check-circle"
|
||||
class="w-5 h-5 text-green-400"
|
||||
/>
|
||||
{:else if print.status === "Fail"}
|
||||
<Icon icon="mdi:close-circle" class="w-5 h-5" />
|
||||
{:else if print.status === "In Progress"}
|
||||
<Icon icon="mdi:loading" class="w-5 h-5 animate-spin" />
|
||||
{:else}
|
||||
<Icon icon="mdi:printer-3d" class="w-5 h-5" />
|
||||
{/if}
|
||||
</div>
|
||||
<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
|
||||
? 'text-red-400 bg-red-500/10'
|
||||
: print.status === 'In Progress'
|
||||
? 'text-blue-400 bg-blue-500/10'
|
||||
: 'text-blue-400 bg-blue-500/10'}"
|
||||
>
|
||||
{#if print.status === "Success"}
|
||||
<Icon
|
||||
icon="mdi:check-circle"
|
||||
class="w-5 h-5 text-green-400"
|
||||
/>
|
||||
{:else if print.status === "Fail"}
|
||||
<Icon
|
||||
icon="mdi:close-circle"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
{:else if print.status === "In Progress"}
|
||||
<Icon
|
||||
icon="mdi:loading"
|
||||
class="w-5 h-5 animate-spin"
|
||||
/>
|
||||
{:else}
|
||||
<Icon
|
||||
icon="mdi:printer-3d"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<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'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: print.status === 'Fail'
|
||||
? 'bg-red-500/10 text-red-400'
|
||||
: 'bg-slate-700 text-slate-400'}"
|
||||
>
|
||||
{print.status}
|
||||
</span>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
{new Date(print.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: print.status === 'Fail'
|
||||
? 'bg-red-500/10 text-red-400'
|
||||
: 'bg-slate-700 text-slate-400'}"
|
||||
>
|
||||
{print.status}
|
||||
</span>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
{new Date(print.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
timeZone: "UTC",
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions / Status -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-semibold text-white/90">Printer Status</h2>
|
||||
<Card>
|
||||
{#if activePrintJob}
|
||||
<!-- Active Print Job -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-medium text-white"
|
||||
>{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"
|
||||
>Printing</span
|
||||
>
|
||||
</div>
|
||||
<!-- Quick Actions / Status -->
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-xl font-semibold text-white/90">Printer Status</h2>
|
||||
<Card>
|
||||
{#if activePrintJob}
|
||||
<!-- Active Print Job -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-medium text-white"
|
||||
>{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"
|
||||
>Printing</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Job Name -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 mb-1">Currently Printing</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{activePrintJob.name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Job Name -->
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 mb-1">
|
||||
Currently Printing
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-white">
|
||||
{activePrintJob.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
class="flex justify-between text-xs text-slate-400 font-medium"
|
||||
>
|
||||
<span>Progress</span>
|
||||
<span class="text-slate-200"
|
||||
>{Math.round(
|
||||
getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes
|
||||
)
|
||||
)}%</span
|
||||
>
|
||||
</div>
|
||||
<div class="h-2 bg-surface-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-1000"
|
||||
style="width: {getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
class="flex justify-between text-xs text-slate-400 font-medium"
|
||||
>
|
||||
<span>Progress</span>
|
||||
<span class="text-slate-200"
|
||||
>{Math.round(
|
||||
getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes,
|
||||
),
|
||||
)}%</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 bg-surface-800 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-1000"
|
||||
style="width: {getProgress(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes,
|
||||
)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time & Material Info -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400">Time Remaining</p>
|
||||
<p class="font-medium text-white">
|
||||
{getTimeRemaining(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400">Filament</p>
|
||||
<p class="font-medium text-white">
|
||||
{activePrintJob.filament_used_g}g
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Time & Material Info -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400">
|
||||
Time Remaining
|
||||
</p>
|
||||
<p class="font-medium text-white">
|
||||
{getTimeRemaining(
|
||||
activePrintJob.started_at,
|
||||
activePrintJob.duration_minutes,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400">Filament</p>
|
||||
<p class="font-medium text-white">
|
||||
{activePrintJob.filament_used_g}g
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spool Info -->
|
||||
{#if activePrintJob.spool_id}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {activePrintJob.spool_id.color_hex}"
|
||||
></div>
|
||||
<span class="text-slate-300"
|
||||
>{activePrintJob.spool_id.brand}
|
||||
{activePrintJob.spool_id.material || ""}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Spool Info -->
|
||||
{#if activePrintJob.spool_id}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: {activePrintJob
|
||||
.spool_id.color_hex}"
|
||||
></div>
|
||||
<span class="text-slate-300"
|
||||
>{activePrintJob.spool_id.brand}
|
||||
{activePrintJob.spool_id.material ||
|
||||
""}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-surface-700/50">
|
||||
<a href="/prints" class="block">
|
||||
<Button variant="secondary" size="sm" class="w-full text-xs"
|
||||
>View All Prints</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{:else if activePrinter}
|
||||
<!-- Idle State -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-medium text-white">{activePrinter.name}</span>
|
||||
<span
|
||||
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"
|
||||
>Idle</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<p class="text-sm text-slate-400 mb-4">No active print job</p>
|
||||
<a href="/prints">
|
||||
<Button variant="primary" size="sm">Start a Print</Button>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-surface-700/50">
|
||||
<a href="/printers" class="block">
|
||||
<Button variant="secondary" size="sm" class="w-full text-xs"
|
||||
>Manage Printer</Button
|
||||
>
|
||||
</a>
|
||||
</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 class="mt-6 pt-4 border-t border-surface-700/50">
|
||||
<a href="/prints" class="block">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full text-xs">View All Prints</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
{:else if activePrinter}
|
||||
<!-- Idle State -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-medium text-white"
|
||||
>{activePrinter.name}</span
|
||||
>
|
||||
<span
|
||||
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"
|
||||
>Idle</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<p class="text-sm text-slate-400 mb-4">
|
||||
No active print job
|
||||
</p>
|
||||
<a href="/prints">
|
||||
<Button variant="primary" size="sm"
|
||||
>Start a Print</Button
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-surface-700/50">
|
||||
<a href="/printers" class="block">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full text-xs">Manage Printer</Button
|
||||
>
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<LogPrintModal
|
||||
open={showQuickLogModal}
|
||||
{printers}
|
||||
{spools}
|
||||
onclose={() => (showQuickLogModal = false)}
|
||||
action="/prints?/log"
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Simple entry animation */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
/* Simple entry animation */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,189 @@
|
||||
import { PrintJob } from '$lib/models/PrintJob';
|
||||
import { Printer } from '$lib/models/Printer';
|
||||
import { connectDB } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
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');
|
||||
|
||||
await connectDB();
|
||||
|
||||
// Fetch all prints for aggregation - filtered by user
|
||||
const prints = await PrintJob.find({ user_id: locals.user.id })
|
||||
.populate('spool_id', 'color_hex material')
|
||||
.populate('printer_id', 'power_consumption_watts')
|
||||
// Get time range from URL param (default: 30 days)
|
||||
const range = url.searchParams.get('range') || '30';
|
||||
let dateFilter: Date | null = null;
|
||||
|
||||
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 })
|
||||
.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 failCount = 0;
|
||||
let cancelledCount = 0;
|
||||
let inProgressCount = 0;
|
||||
|
||||
// 2. Material Usage (Map: Material -> Weight)
|
||||
const materialUsage: Record<string, number> = {};
|
||||
|
||||
// 3. Usage Over Time (Last 30 days)
|
||||
// 3. Usage Over Time
|
||||
const usageByDate: Record<string, number> = {};
|
||||
|
||||
// 4. Electricity Usage Over Time (Wh)
|
||||
const electricityByDate: Record<string, number> = {};
|
||||
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 => {
|
||||
// Status
|
||||
if (print.status === 'Success') successCount++;
|
||||
else if (print.status === 'Fail') failCount++;
|
||||
else if (print.status === 'Cancelled') cancelledCount++;
|
||||
else if (print.status === 'In Progress') inProgressCount++;
|
||||
|
||||
// Material
|
||||
if (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
|
||||
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
|
||||
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 wattHours = powerWatts * durationHours;
|
||||
|
||||
electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + 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 {
|
||||
analytics: {
|
||||
successRate: { success: successCount, fail: failCount },
|
||||
successRate: {
|
||||
success: successCount,
|
||||
fail: failCount,
|
||||
cancelled: cancelledCount,
|
||||
inProgress: inProgressCount
|
||||
},
|
||||
materialUsage,
|
||||
usageByDate,
|
||||
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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,53 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import Chart from "chart.js/auto";
|
||||
import Card from "$lib/components/ui/Card.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
let { data } = $props();
|
||||
// svelte-ignore non_reactive_update
|
||||
let analytics = $derived(data.analytics);
|
||||
|
||||
// svelte-ignore non_reactive_update
|
||||
let timelineCanvas: HTMLCanvasElement;
|
||||
// svelte-ignore non_reactive_update
|
||||
let pieCanvas: HTMLCanvasElement;
|
||||
// svelte-ignore non_reactive_update
|
||||
let electricityCanvas: HTMLCanvasElement;
|
||||
// svelte-ignore non_reactive_update
|
||||
let costCanvas: HTMLCanvasElement;
|
||||
// svelte-ignore non_reactive_update
|
||||
let printerCanvas: HTMLCanvasElement;
|
||||
|
||||
onMount(() => {
|
||||
// 1. Timeline Chart - Filament Usage
|
||||
const dates = Object.keys(analytics.usageByDate).slice(-30);
|
||||
const weights = dates.map((d) => analytics.usageByDate[d]);
|
||||
// Time range options
|
||||
const ranges = [
|
||||
{ value: "7", label: "7 Days" },
|
||||
{ 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, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
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)" } },
|
||||
x: { grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
function changeRange(range: string) {
|
||||
goto(`/analytics?range=${range}`);
|
||||
}
|
||||
|
||||
// Format minutes to hours:minutes
|
||||
function formatTime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
// Format bytes to human readable
|
||||
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];
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
@@ -57,92 +76,309 @@
|
||||
"#64748b",
|
||||
];
|
||||
|
||||
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" } },
|
||||
// 1. Timeline Chart - Filament Usage
|
||||
if (timelineCanvas) {
|
||||
const dates = Object.keys(analytics.usageByDate).slice(-30);
|
||||
const weights = dates.map((d) => analytics.usageByDate[d]);
|
||||
|
||||
timelineChart = new Chart(timelineCanvas, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates.map((d) =>
|
||||
new Date(d).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
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
|
||||
const electricDates = Object.keys(analytics.electricityByDate).slice(
|
||||
-30,
|
||||
);
|
||||
const electricityWh = electricDates.map(
|
||||
(d) => analytics.electricityByDate[d] / 1000,
|
||||
); // Convert to kWh
|
||||
if (electricityCanvas) {
|
||||
const electricDates = Object.keys(
|
||||
analytics.electricityByDate,
|
||||
).slice(-30);
|
||||
const electricityWh = electricDates.map(
|
||||
(d) => analytics.electricityByDate[d] / 1000,
|
||||
);
|
||||
|
||||
new Chart(electricityCanvas, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: electricDates,
|
||||
datasets: [
|
||||
{
|
||||
label: "Electricity (kWh)",
|
||||
data: electricityWh,
|
||||
backgroundColor: "rgba(245, 158, 11, 0.6)",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
electricityChart = new Chart(electricityCanvas, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: electricDates.map((d) =>
|
||||
new Date(d).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: "Electricity (kWh)",
|
||||
data: electricityWh,
|
||||
backgroundColor: "rgba(245, 158, 11, 0.6)",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.1)" },
|
||||
title: { display: true, text: "kWh", color: "#94a3b8" },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
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(
|
||||
analytics.successRate.success + analytics.successRate.fail,
|
||||
analytics.successRate.success +
|
||||
analytics.successRate.fail +
|
||||
analytics.successRate.cancelled,
|
||||
);
|
||||
let successRate = $derived(
|
||||
totalPrints > 0
|
||||
? Math.round((analytics.successRate.success / totalPrints) * 100)
|
||||
: 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>
|
||||
|
||||
<div class="space-y-6 fade-in">
|
||||
<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
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- Main Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<Card class="text-center">
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Total Prints
|
||||
</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 class="text-center">
|
||||
<p
|
||||
@@ -150,7 +386,7 @@
|
||||
>
|
||||
Success Rate
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-emerald-400 mt-1">
|
||||
<p class="text-2xl font-bold text-emerald-400 mt-1">
|
||||
{successRate}%
|
||||
</p>
|
||||
</Card>
|
||||
@@ -158,9 +394,31 @@
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Electricity Used
|
||||
Total Spent
|
||||
</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
|
||||
class="text-sm font-normal text-slate-500 ml-1">kWh</span
|
||||
>
|
||||
@@ -170,37 +428,118 @@
|
||||
<p
|
||||
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||
>
|
||||
Materials
|
||||
Print Time
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-violet-400 mt-1">
|
||||
{Object.keys(analytics.materialUsage).length}
|
||||
<p class="text-2xl font-bold text-violet-400 mt-1">
|
||||
{formatTime(analytics.totalPrintTime)}
|
||||
</p>
|
||||
</Card>
|
||||
</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 -->
|
||||
<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">
|
||||
<Icon icon="mdi:scale" class="w-5 h-5 text-blue-400" />
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
Daily Filament Usage (g)
|
||||
</h3>
|
||||
<Icon icon="mdi:chart-line" class="w-5 h-5 text-blue-400" />
|
||||
<h3 class="text-lg font-semibold text-white">Filament Usage</h3>
|
||||
</div>
|
||||
<div class="h-[280px]">
|
||||
<canvas bind:this={timelineCanvas}></canvas>
|
||||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
<Icon
|
||||
icon="mdi:lightning-bolt"
|
||||
class="w-5 h-5 text-amber-400"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
Daily Electricity Usage (kWh)
|
||||
Electricity Usage
|
||||
</h3>
|
||||
</div>
|
||||
<div class="h-[280px]">
|
||||
@@ -208,7 +547,22 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="relative w-32 h-32">
|
||||
<svg class="w-full h-full transform -rotate-90">
|
||||
@@ -244,23 +598,84 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-400 mt-4 text-sm">
|
||||
{analytics.successRate.success} Success / {analytics.successRate
|
||||
.fail} Fail
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||
<span class="flex items-center gap-1 text-emerald-400">
|
||||
<Icon icon="mdi:check-circle" class="w-4 h-4" />
|
||||
{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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<Icon icon="mdi:cube-scan" class="w-5 h-5 text-violet-400" />
|
||||
<h3 class="text-lg font-semibold text-white">3D Models</h3>
|
||||
</div>
|
||||
<div class="h-[250px]">
|
||||
<canvas bind:this={pieCanvas}></canvas>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<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>
|
||||
{#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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
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 }) => {
|
||||
if (!locals.user) {
|
||||
@@ -24,7 +24,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const extension = '.' + fileName.split('.').pop();
|
||||
|
||||
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
|
||||
|
||||
@@ -2,6 +2,8 @@ import { PrintJob } from '$lib/models/PrintJob';
|
||||
import { connectDB } from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { statSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) throw redirect(303, '/login');
|
||||
@@ -16,7 +18,31 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
.sort({ date: -1 })
|
||||
.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 {
|
||||
models: JSON.parse(JSON.stringify(printsWithSTL))
|
||||
models: JSON.parse(JSON.stringify(modelsWithSizes)),
|
||||
totalStorage
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
let { data } = $props();
|
||||
let models = $derived(data.models);
|
||||
let totalStorage = $derived(data.totalStorage);
|
||||
|
||||
let selectedModel = $state<any>(null);
|
||||
let showViewer = $state(false);
|
||||
@@ -24,16 +25,37 @@
|
||||
month: "short",
|
||||
day: "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>
|
||||
|
||||
<div class="space-y-6 fade-in">
|
||||
<div>
|
||||
<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
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{#if models.length === 0}
|
||||
@@ -129,6 +151,13 @@
|
||||
Failed
|
||||
</span>
|
||||
{/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>
|
||||
</Card>
|
||||
@@ -205,6 +234,12 @@
|
||||
${selectedModel.calculated_cost_filament.toFixed(2)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedModel.fileSize}
|
||||
<div class="text-slate-400">
|
||||
<span class="text-slate-500">File Size:</span>
|
||||
{formatBytes(selectedModel.fileSize)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,17 +5,20 @@ import { User } from '$lib/models/User';
|
||||
import { connectDB } from '$lib/server/db';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
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 }) => {
|
||||
if (!locals.user) throw redirect(303, '/login');
|
||||
|
||||
|
||||
await connectDB();
|
||||
|
||||
|
||||
// Fetch prints with populated fields (Spool and Printer) - filtered by user
|
||||
const prints = await PrintJob.find({ user_id: locals.user.id })
|
||||
.populate('spool_id', 'brand material color_hex')
|
||||
.populate('printer_id', 'name model')
|
||||
.sort({ date: -1 })
|
||||
.sort({ date: -1, _id: -1 })
|
||||
.lean();
|
||||
|
||||
// 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(),
|
||||
Printer.find({ user_id: locals.user.id }).lean()
|
||||
]);
|
||||
|
||||
|
||||
return {
|
||||
prints: JSON.parse(JSON.stringify(prints)),
|
||||
spools: JSON.parse(JSON.stringify(spools)),
|
||||
@@ -34,7 +37,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
export const actions: Actions = {
|
||||
log: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { unauthorized: true });
|
||||
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name');
|
||||
const spool_id = formData.get('spool_id');
|
||||
@@ -45,6 +48,7 @@ export const actions: Actions = {
|
||||
const manual_cost = formData.get('manual_cost');
|
||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||
const stl_file = formData.get('stl_file');
|
||||
const date = formData.get('date');
|
||||
|
||||
if (!spool_id || !printer_id || !filament_used_g) {
|
||||
return fail(400, { missing: true });
|
||||
@@ -67,7 +71,7 @@ export const actions: Actions = {
|
||||
|
||||
const weightUsed = Number(filament_used_g);
|
||||
const durationMins = Number(duration_minutes);
|
||||
|
||||
|
||||
// Calculate Filament Cost: use manual if provided, otherwise calculate
|
||||
let costFilament: number;
|
||||
if (manual_cost && String(manual_cost).trim() !== '') {
|
||||
@@ -92,14 +96,14 @@ export const actions: Actions = {
|
||||
|
||||
// 2. Create Print Job
|
||||
const isInProgress = status === 'In Progress';
|
||||
|
||||
|
||||
// Calculate started_at based on elapsed time
|
||||
let startedAt: Date | null = null;
|
||||
if (isInProgress) {
|
||||
const elapsedMs = Number(elapsed_minutes || 0) * 60 * 1000;
|
||||
startedAt = new Date(Date.now() - elapsedMs);
|
||||
}
|
||||
|
||||
|
||||
await PrintJob.create({
|
||||
user_id: locals.user.id,
|
||||
name: name || 'Untitled Print',
|
||||
@@ -112,7 +116,7 @@ export const actions: Actions = {
|
||||
status,
|
||||
started_at: startedAt,
|
||||
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)
|
||||
@@ -130,7 +134,7 @@ export const actions: Actions = {
|
||||
|
||||
edit: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { unauthorized: true });
|
||||
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id');
|
||||
const name = formData.get('name');
|
||||
@@ -142,6 +146,8 @@ export const actions: Actions = {
|
||||
const printer_id = formData.get('printer_id');
|
||||
const spool_id = formData.get('spool_id');
|
||||
const stl_file = formData.get('stl_file');
|
||||
const remove_model = formData.get('remove_model');
|
||||
const date = formData.get('date');
|
||||
|
||||
if (!id || !name) {
|
||||
return fail(400, { missing: true });
|
||||
@@ -159,19 +165,24 @@ export const actions: Actions = {
|
||||
|
||||
const weightUsed = Number(filament_used_g);
|
||||
const durationMins = Number(duration_minutes);
|
||||
|
||||
|
||||
// Get printer for power calculation
|
||||
const printerForCalc = printer_id
|
||||
const printerForCalc = printer_id
|
||||
? await Printer.findById(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
|
||||
let costFilament: number;
|
||||
if (manual_cost && String(manual_cost).trim() !== '') {
|
||||
// Manual cost is the total, we'll calculate energy separately for tracking
|
||||
costFilament = Number(manual_cost);
|
||||
} else if (printJob.spool_id?.price && printJob.spool_id?.weight_initial_g) {
|
||||
costFilament = (printJob.spool_id.price / printJob.spool_id.weight_initial_g) * weightUsed;
|
||||
} else if (spoolForCalc?.price && spoolForCalc?.weight_initial_g) {
|
||||
costFilament = (spoolForCalc.price / spoolForCalc.weight_initial_g) * weightUsed;
|
||||
} else {
|
||||
costFilament = 0;
|
||||
}
|
||||
@@ -192,7 +203,7 @@ export const actions: Actions = {
|
||||
// Calculate started_at based on elapsed time for In Progress
|
||||
const isInProgress = status === 'In Progress';
|
||||
let startedAt = printJob.started_at;
|
||||
|
||||
|
||||
if (isInProgress && elapsed_minutes) {
|
||||
const elapsedMs = Number(elapsed_minutes) * 60 * 1000;
|
||||
startedAt = new Date(Date.now() - elapsedMs);
|
||||
@@ -208,19 +219,42 @@ export const actions: Actions = {
|
||||
calculated_cost_filament: Number(totalCost.toFixed(2)),
|
||||
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
||||
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) {
|
||||
updateData.printer_id = printer_id;
|
||||
}
|
||||
if (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 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;
|
||||
} 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(
|
||||
@@ -237,7 +271,7 @@ export const actions: Actions = {
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { unauthorized: true });
|
||||
|
||||
|
||||
const formData = await request.formData();
|
||||
const id = formData.get('id');
|
||||
|
||||
@@ -246,7 +280,89 @@ export const actions: Actions = {
|
||||
await connectDB();
|
||||
|
||||
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 });
|
||||
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 };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -106,9 +106,10 @@
|
||||
>
|
||||
<span>•</span>
|
||||
<span
|
||||
>{new Date(
|
||||
print.date,
|
||||
).toLocaleDateString()}</span
|
||||
>{new Date(print.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{ timeZone: "UTC" },
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user