Initial Code Commit

This commit is contained in:
2025-12-25 07:23:24 +00:00
commit 920f892ca7
56 changed files with 5043 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vscode/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"useTabs": true
}

198
README.md Normal file
View File

@@ -0,0 +1,198 @@
# 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.
## 🛠️ Technology Stack
- **Framework:** SvelteKit (Svelte 5)
- **Language:** TypeScript
- **Styling:** Tailwind CSS v4 (Cerberus Theme)
- **State Management:** Svelte 5 Runes
- **Build Tool:** Vite
- **Data Visualization:** Chart.js
- **Icons:** Iconify (@iconify/svelte)
- **Database:** MongoDB with Mongoose
- **Authentication:** JWT with bcrypt password hashing
## ✨ Features
### 1. Dashboard
- **Overview Stats:** Active spools, filament on hand, printers, estimated value, and total spent.
- **Recent Activity:** Quick view of the 5 most recent prints with status indicators.
- **Printer Status:** Shows active print job with real-time countdown and progress bar.
- **Browser Notifications:** Get notified when a print job completes.
### 2. Filament Inventory Management
- **Spool Tracking:**
- Brand, Material (PLA, PETG, ABS, ASA, TPU, Other), Color (with hex preview).
- Initial Weight vs. Remaining Weight.
- Cost per spool and automatic cost-per-gram calculation.
- Purchase date tracking.
- **Edit/Delete:** Full CRUD operations for spool management.
- **Visual Indicators:** Color preview badges and remaining weight display.
### 3. Print Job Logging
- **Log Prints:**
- Link to specific Printer and Filament Spool.
- Duration (minutes) and Weight used (g).
- Calculated Cost (auto-calculated or manual override).
- Status: Success, Fail, Cancelled, **In Progress**.
- **In Progress Tracking:**
- Assign printer and spool to active jobs.
- Specify elapsed time for accurate dashboard countdown.
- Real-time progress display on dashboard.
- **Edit/Delete:** Full CRUD operations for print history.
- **History:** Clickable entries with detailed information.
### 4. 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
- **Daily Filament Usage:** Line chart showing filament consumption over time.
- **Daily Electricity Usage:** Bar chart showing power consumption in kWh.
- **Success Rate:** Visual ring chart with percentage.
- **Material Distribution:** Doughnut chart showing material breakdown.
- **Stats Summary:** Total prints, success rate, total electricity used.
### 6. User Management
- **Authentication:** Secure login/registration with JWT tokens.
- **User Settings:** Profile editing and 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
### Prerequisites
- Node.js 18+ or Bun
- MongoDB instance (local or Atlas)
### Installation
```bash
# Clone the repository
git clone https://github.com/yourusername/filaprint.git
cd filaprint
# Install dependencies
bun install
# Set up environment variables
cp .env.example .env
# Edit .env with your MongoDB URI and JWT secret
# Run development server
bun run dev
```
### Environment Variables
```env
MONGODB_URI=mongodb://localhost:27017/filaprint
JWT_SECRET=your-super-secret-jwt-key
```
## 📁 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
- [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] Filament deduction on print completion
- [x] Analytics with Chart.js (filament, electricity, materials)
- [x] User settings (profile, password change)
- [x] Admin user management panel
- [x] Browser notifications for completed prints
- [x] Iconify icon library integration
- [x] Responsive design
## 🔮 Future Enhancements
- [ ] 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

636
bun.lock Normal file
View File

@@ -0,0 +1,636 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "filaprint",
"dependencies": {
"@iconify/svelte": "^5.1.0",
"@sveltejs/adapter-node": "^5.4.0",
"@threlte/core": "^8.3.1",
"@threlte/extras": "^9.7.1",
"@types/three": "^0.182.0",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"cookie": "^1.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.0.2",
"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",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@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",
"typescript": "^5.9.3",
"vite": "^7.2.6",
},
},
},
"packages": {
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@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/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.4", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
"@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/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/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-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=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@threejs-kit/instanced-sprite-mesh": ["@threejs-kit/instanced-sprite-mesh@2.5.1", "", { "dependencies": { "diet-sprite": "^0.0.1", "earcut": "^2.2.4", "maath": "^0.10.7", "three-instanced-uniforms-mesh": "^0.52.4", "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.170.0" } }, "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA=="],
"@threlte/core": ["@threlte/core@8.3.1", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-qKjjNCQ+40hyeBcfOMh/8ef5x/j5PG5Wmo/L9Ye0aDCcdD6fCewWxfp7tV/J3CxPzX1dEp1JGK7sjyc7ntZSrg=="],
"@threlte/extras": ["@threlte/extras@9.7.1", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-SGm59HDCdHxADFHuweHfFDknwubkCPodyK0pbfsVtOWWOX26gE2xfK7aKolh6YFDiPAjWjGxN0jIgkNbbr1ohg=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@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/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
"@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="],
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
"diet-sprite": ["diet-sprite@0.0.1", "", {}, "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"earcut": ["earcut@2.2.4", "", {}, "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"kareem": ["kareem@3.0.0", "", {}, "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="],
"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=="],
"mpath": ["mpath@0.9.0", "", {}, "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="],
"mquery": ["mquery@6.0.0", "", {}, "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"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=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sift": ["sift@17.1.3", "", {}, "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"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-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=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="],
"three-instanced-uniforms-mesh": ["three-instanced-uniforms-mesh@0.52.4", "", { "dependencies": { "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng=="],
"three-mesh-bvh": ["three-mesh-bvh@0.9.4", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-+y6xLS6k5LWkNNhYsTgKXBC2D9r/z0swiehVHYhZZ8AOhaKDRCWKsN94ctV5Xy7xA4Xbnv4LKYzf7epRLPT6oQ=="],
"three-perf": ["three-perf@1.0.11", "", { "dependencies": { "troika-three-text": "^0.52.0", "tweakpane": "^3.1.10" }, "peerDependencies": { "three": ">=0.170" } }, "sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ=="],
"three-viewport-gizmo": ["three-viewport-gizmo@2.2.0", "", { "peerDependencies": { "three": ">=0.162.0 <1.0.0" } }, "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="],
"troika-three-utils": ["troika-three-utils@0.52.4", "", { "peerDependencies": { "three": ">=0.125.0" } }, "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A=="],
"troika-worker-utils": ["troika-worker-utils@0.52.0", "", {}, "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw=="],
"tweakpane": ["tweakpane@3.1.10", "", {}, "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"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=="],
"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=="],
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@sveltejs/kit/@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@sveltejs/kit/cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
}
}

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "filaprint",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"start": "node server/server.js"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@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",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"dependencies": {
"@iconify/svelte": "^5.1.0",
"@sveltejs/adapter-node": "^5.4.0",
"@threlte/core": "^8.3.1",
"@threlte/extras": "^9.7.1",
"@types/three": "^0.182.0",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"cookie": "^1.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.0.2",
"three": "^0.182.0"
}
}

16
server/db.js Normal file
View File

@@ -0,0 +1,16 @@
import mongoose from 'mongoose';
import dotenv from 'dotenv';
dotenv.config();
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/filaprint';
export const connectDB = async () => {
try {
await mongoose.connect(MONGODB_URI);
console.log('MongoDB connected successfully');
} catch (error) {
console.error('MongoDB connection error:', error);
process.exit(1);
}
};

23
server/server.js Normal file
View File

@@ -0,0 +1,23 @@
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';
dotenv.config();
const app = express();
const server = http.Server(app);
app.use(cors());
// Connect to Database
connectDB();
app.use(handler);
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log('listening on port http://localhost:' + PORT);
});

137
src/app.css Normal file
View File

@@ -0,0 +1,137 @@
@import "tailwindcss";
/*
Cerberus Theme - Exact Implementation
Using the official Cerberus theme variables
*/
:root {
/* Spacing & Typography */
--spacing: 0.25rem;
--text-scaling: 1.067;
/* 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);
/* 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);
/* 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);
/* 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);
/* 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;
}
@theme {
--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);
--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-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;
}
/* 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;
}
.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);
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
declare global {
namespace App {
// interface Error {}
interface Locals {
user: { id: string; username: string; role: string } | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

43
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,43 @@
import { connectDB } from '$lib/server/db';
import { User } from '$lib/models/User';
import type { Handle } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_change_me';
export const handle: Handle = async ({ event, resolve }) => {
// 1. Connect to DB on every request (ensure connection)
await connectDB();
// 2. Auth Check
const token = event.cookies.get('session');
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string; username: string; role: string };
// Optional: Fetch full user if needed, but token payload is faster
event.locals.user = { id: decoded.id, username: decoded.username, role: decoded.role };
} catch (err) {
// Invalid token
event.cookies.delete('session', { path: '/' });
event.locals.user = null;
}
} else {
event.locals.user = null;
}
// 3. Route Protection
if (!event.locals.user) {
if (!event.url.pathname.startsWith('/login') && !event.url.pathname.startsWith('/register')) {
return new Response('Redirect', { status: 303, headers: { Location: '/login' } });
}
} else {
// If logged in and trying to go to login/register, redirect to dashboard
if (event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/register')) {
return new Response('Redirect', { status: 303, headers: { Location: '/' } });
}
}
const response = await resolve(event);
return response;
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { T } from "@threlte/core";
import { Float, OrbitControls } from "@threlte/extras";
interface Props {
color?: string;
percentage?: number; // 0 to 100
}
let { color = "#ffffff", percentage = 100 }: Props = $props();
// Filament radius calculation approximation
// Max radius (full) = 3, Min radius (empty) = 1.2 (hub size)
// Area is proportional to weight/volume.
// Area_fil = pi * (R^2 - r_hub^2)
// Current Area = (percentage/100) * Max_Area
// R_current = sqrt( Current Area / pi + r_hub^2 )
const r_hub = 1.2;
const r_max = 3;
const area_max = Math.PI * (r_max ** 2 - r_hub ** 2);
let current_area = $derived((percentage / 100) * area_max);
let r_current = $derived(Math.sqrt(current_area / Math.PI + r_hub ** 2));
</script>
<Float floatIntensity={0.5} rotationIntensity={0.5} speed={2}>
<!-- Spool Flanges (Sides) -->
<T.Group rotation.z={Math.PI / 2}>
<!-- Left Flange -->
<T.Mesh position.y={1.1}>
<T.CylinderGeometry args={[3.2, 3.2, 0.2, 32]} />
<T.MeshStandardMaterial color="#1e1e1e" roughness={0.5} metalness={0.1} />
</T.Mesh>
<!-- Right Flange -->
<T.Mesh position.y={-1.1}>
<T.CylinderGeometry args={[3.2, 3.2, 0.2, 32]} />
<T.MeshStandardMaterial color="#1e1e1e" roughness={0.5} metalness={0.1} />
</T.Mesh>
<!-- Center Hub (Hidden mostly) -->
<T.Mesh>
<T.CylinderGeometry args={[1.1, 1.1, 2.2, 32]} />
<T.MeshStandardMaterial color="#1e1e1e" />
</T.Mesh>
<!-- Filament Volume -->
<T.Mesh>
<T.CylinderGeometry args={[r_current, r_current, 2, 32]} />
<T.MeshStandardMaterial {color} roughness={0.3} metalness={0.0} />
</T.Mesh>
</T.Group>
</Float>
<OrbitControls autoRotate enableZoom={false} enablePan={false} />

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { page } from "$app/stores";
import Icon from "@iconify/svelte";
let { data } = $props();
let mobileMenuOpen = $state(false);
let links = $derived([
{ name: "Dashboard", href: "/", icon: "mdi:view-dashboard" },
{ name: "Spools", href: "/spools", icon: "mdi:cylinder" },
{ name: "Printers", href: "/printers", icon: "mdi:printer-3d" },
{ name: "Prints", href: "/prints", icon: "mdi:cube-outline" },
{ name: "Analytics", href: "/analytics", icon: "mdi:chart-line" },
...($page.data.user?.role === "Admin"
? [
{
name: "Users",
href: "/admin/users",
icon: "mdi:account-group",
},
]
: []),
]);
function closeMobileMenu() {
mobileMenuOpen = false;
}
</script>
<!-- Desktop Navbar -->
<nav
class="fixed top-0 left-0 h-full w-64 bg-surface-900 border-r border-surface-700/50 p-4 hidden md:flex flex-col z-50"
>
<div class="mb-8 px-2">
<h1
class="text-2xl font-bold bg-linear-to-r from-primary to-accent bg-clip-text text-transparent"
>
Filaprint
</h1>
<p class="text-xs text-text-muted mt-1">3D Printer Manager</p>
</div>
<div class="space-y-1">
{#each links as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200
{$page.url.pathname === link.href
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-text-muted hover:text-text-main hover:bg-surface-800'}"
>
<Icon icon={link.icon} class="w-5 h-5" />
{link.name}
</a>
{/each}
</div>
<div class="mt-auto pt-4 border-t border-surface-700/50">
{#if $page.data.user}
<div class="flex items-center px-2 py-2">
<a
href="/settings"
class="flex items-center flex-1 min-w-0 hover:opacity-80 transition-opacity"
>
<div
class="w-8 h-8 rounded-full bg-linear-to-br from-primary to-accent"
></div>
<div class="ml-3 flex-1 min-w-0">
<p class="text-sm font-medium text-text-main truncate">
{$page.data.user.username}
</p>
<p class="text-xs text-text-muted">
{$page.data.user.role || "Maker"}
</p>
</div>
</a>
<form action="/logout" method="POST">
<button
type="submit"
class="p-1.5 text-text-muted hover:text-red-400 transition-colors"
title="Sign Out"
>
<Icon icon="mdi:logout" class="w-5 h-5" />
</button>
</form>
</div>
{:else}
<div class="px-2 py-2">
<a
href="/login"
class="text-sm text-primary hover:text-primary-400 font-medium"
>Sign In</a
>
</div>
{/if}
</div>
</nav>
<!-- Mobile Header -->
<div
class="md:hidden fixed top-0 w-full h-16 border-b border-surface-700/50 flex items-center justify-between px-4 z-50"
style="background-color: #18181b;"
>
<span
class="text-xl font-bold bg-linear-to-r from-primary to-accent bg-clip-text text-transparent"
>
Filaprint
</span>
<button
class="text-text-muted p-2 hover:bg-surface-800 rounded-lg transition-colors"
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
>
<Icon
icon={mobileMenuOpen ? "mdi:close" : "mdi:menu"}
class="w-6 h-6"
/>
</button>
</div>
<!-- Mobile Menu Overlay -->
{#if mobileMenuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="md:hidden fixed inset-0 bg-black/70 z-40"
onclick={closeMobileMenu}
></div>
<div
class="md:hidden fixed top-16 left-0 right-0 z-50 p-4 space-y-2 animate-slide-down border-b border-surface-700/50"
style="background-color: #18181b;"
>
{#each links as link}
<a
href={link.href}
onclick={closeMobileMenu}
class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200
{$page.url.pathname === link.href
? 'bg-primary/10 text-primary border border-primary/20'
: 'text-text-muted hover:text-text-main hover:bg-surface-800'}"
>
<Icon icon={link.icon} class="w-5 h-5" />
{link.name}
</a>
{/each}
<div class="border-t border-surface-700/50 pt-3 mt-3">
{#if $page.data.user}
<a
href="/settings"
onclick={closeMobileMenu}
class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-main hover:bg-surface-800 transition-all"
>
<div
class="w-8 h-8 rounded-full bg-linear-to-br from-primary to-accent"
></div>
<div>
<p class="text-text-main">{$page.data.user.username}</p>
<p class="text-xs text-text-muted">
{$page.data.user.role || "Maker"}
</p>
</div>
</a>
<form action="/logout" method="POST" class="mt-2">
<button
type="submit"
class="flex items-center gap-3 w-full px-4 py-3 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/10 transition-all"
>
<Icon icon="mdi:logout" class="w-5 h-5" />
Sign Out
</button>
</form>
{:else}
<a
href="/login"
onclick={closeMobileMenu}
class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-primary hover:bg-primary/10 transition-all"
>
<Icon icon="mdi:login" class="w-5 h-5" />
Sign In
</a>
{/if}
</div>
</div>
{/if}
<style>
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-down {
animation: slideDown 0.2s ease-out;
}
</style>

View File

@@ -0,0 +1,259 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import Input from "$lib/components/ui/Input.svelte";
import Icon from "@iconify/svelte";
interface Props {
open: boolean;
print: any;
printers: any[];
spools: any[];
onclose: () => void;
}
let { open, print, printers, spools, onclose }: Props = $props();
let isSubmitting = $state(false);
let editStatus = $state("Success");
// Update editStatus when print changes
$effect(() => {
if (print) {
editStatus = print.status || "Success";
}
});
function handleClose() {
editStatus = "Success";
onclose();
}
async function handleDelete() {
if (!confirm("Are you sure you want to delete this print log?")) return;
isSubmitting = true;
const formData = new FormData();
formData.append("id", print._id);
await fetch("?/delete", { method: "POST", body: formData });
isSubmitting = false;
handleClose();
window.location.reload();
}
</script>
<Modal title="Edit Print Log" {open} onclose={handleClose}>
{#if print}
<form
method="POST"
action="?/edit"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
handleClose();
};
}}
class="space-y-4"
>
<input type="hidden" name="id" value={print._id} />
<!-- Status Selection -->
<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"
>Status</label
>
<div class="grid grid-cols-2 gap-2">
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="In Progress"
class="peer sr-only"
bind:group={editStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-blue-500/20 peer-checked:text-blue-400 peer-checked:border-blue-500/50 transition-all"
>
<Icon icon="mdi:printer-3d" class="w-4 h-4" /> In Progress
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Success"
class="peer sr-only"
bind:group={editStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-green-500/20 peer-checked:text-green-400 peer-checked:border-green-500/50 transition-all"
>
<Icon icon="mdi:check" class="w-4 h-4" /> Success
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Fail"
class="peer sr-only"
bind:group={editStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-red-500/20 peer-checked:text-red-400 peer-checked:border-red-500/50 transition-all"
>
<Icon icon="mdi:close" class="w-4 h-4" /> Fail
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Cancelled"
class="peer sr-only"
bind:group={editStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-slate-600/20 peer-checked:text-slate-300 peer-checked:border-slate-500/50 transition-all"
>
<Icon icon="mdi:cancel" class="w-4 h-4" /> Cancelled
</div>
</label>
</div>
</div>
<Input label="Print Name" name="name" value={print.name} required />
{#if editStatus === "In Progress"}
<!-- In Progress specific fields -->
<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="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<p class="text-xs text-blue-300 mb-3">
<Icon icon="mdi:information" class="w-4 h-4 inline mr-1" />
Update the total print time and how long it's been running.
</p>
<div class="grid grid-cols-2 gap-4">
<Input
label="Total Duration (min)"
name="duration_minutes"
type="number"
value={print.duration_minutes}
required
/>
<Input
label="Already Elapsed (min)"
name="elapsed_minutes"
type="number"
placeholder="0"
value="0"
/>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<Input
label="Expected Filament (g)"
name="filament_used_g"
type="number"
value={print.filament_used_g}
required
/>
<Input
label="Cost ($)"
name="manual_cost"
type="number"
step="0.01"
value={print.calculated_cost_filament}
/>
</div>
</div>
{:else}
<!-- Completed print fields -->
<div class="grid grid-cols-3 gap-4">
<Input
label="Duration (min)"
name="duration_minutes"
type="number"
value={print.duration_minutes}
required
/>
<Input
label="Used (g)"
name="filament_used_g"
type="number"
value={print.filament_used_g}
required
/>
<Input
label="Cost ($)"
name="manual_cost"
type="number"
step="0.01"
value={print.calculated_cost_filament}
/>
</div>
{/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
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</form>
{/if}
</Modal>

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import Input from "$lib/components/ui/Input.svelte";
import Icon from "@iconify/svelte";
interface Props {
open: boolean;
printers: any[];
spools: any[];
onclose: () => void;
}
let { open, printers, spools, onclose }: Props = $props();
let isSubmitting = $state(false);
let selectedStatus = $state("Success");
function handleClose() {
selectedStatus = "Success";
onclose();
}
</script>
<Modal title="Log a Print" {open} onclose={handleClose}>
<form
method="POST"
action="?/log"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
handleClose();
};
}}
class="space-y-4"
>
<!-- Status Selection First -->
<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"
>Status</label
>
<div class="grid grid-cols-2 gap-2">
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="In Progress"
class="peer sr-only"
bind:group={selectedStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-blue-500/20 peer-checked:text-blue-400 peer-checked:border-blue-500/50 transition-all"
>
<Icon icon="mdi:printer-3d" class="w-4 h-4" /> In Progress
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Success"
class="peer sr-only"
bind:group={selectedStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-green-500/20 peer-checked:text-green-400 peer-checked:border-green-500/50 transition-all"
>
<Icon icon="mdi:check" class="w-4 h-4" /> Success
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Fail"
class="peer sr-only"
bind:group={selectedStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-red-500/20 peer-checked:text-red-400 peer-checked:border-red-500/50 transition-all"
>
<Icon icon="mdi:close" class="w-4 h-4" /> Fail
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="status"
value="Cancelled"
class="peer sr-only"
bind:group={selectedStatus}
/>
<div
class="flex items-center justify-center gap-2 py-2 rounded-lg border border-slate-700 text-slate-400 peer-checked:bg-slate-600/20 peer-checked:text-slate-300 peer-checked:border-slate-500/50 transition-all"
>
<Icon icon="mdi:cancel" class="w-4 h-4" /> Cancelled
</div>
</label>
</div>
</div>
<Input
label="Print Name"
name="name"
placeholder="Dragon Scale Mail"
required
/>
<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}>{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}
>{s.brand} {s.material} ({s.weight_remaining_g}g left)</option
>
{/each}
</select>
</div>
</div>
{#if selectedStatus === "In Progress"}
<!-- In Progress specific fields -->
<div class="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<p class="text-xs text-blue-300 mb-3">
<Icon icon="mdi:information" class="w-4 h-4 inline mr-1" />
Enter the expected total print time and how long it's been running.
</p>
<div class="grid grid-cols-2 gap-4">
<Input
label="Total Duration (min)"
name="duration_minutes"
type="number"
placeholder="120"
required
/>
<Input
label="Already Elapsed (min)"
name="elapsed_minutes"
type="number"
placeholder="0"
value="0"
/>
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<Input
label="Expected Filament (g)"
name="filament_used_g"
type="number"
placeholder="50"
required
/>
<Input
label="Cost ($)"
name="manual_cost"
type="number"
step="0.01"
placeholder="Auto"
/>
</div>
</div>
{:else}
<!-- Completed print fields -->
<div class="grid grid-cols-3 gap-4">
<Input
label="Duration (min)"
name="duration_minutes"
type="number"
placeholder="60"
required
/>
<Input
label="Used (g)"
name="filament_used_g"
type="number"
placeholder="15"
required
/>
<Input
label="Cost ($)"
name="manual_cost"
type="number"
step="0.01"
placeholder="Auto"
/>
</div>
{/if}
<div class="pt-4 flex justify-end gap-3">
<Button variant="ghost" onclick={handleClose} type="button">Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Saving..."
: selectedStatus === "In Progress"
? "Start Print"
: "Save Log"}
</Button>
</div>
</form>
</Modal>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { type Snippet } from "svelte";
interface Props {
children: Snippet;
variant?: "primary" | "secondary" | "ghost" | "destructive";
size?: "sm" | "md" | "lg";
class?: string;
disabled?: boolean;
onclick?: () => void;
type?: "button" | "submit" | "reset";
}
let {
children,
variant = "primary",
size = "md",
class: className = "",
disabled = false,
onclick,
type = "button",
}: Props = $props();
const variants = {
primary:
"bg-primary text-primary-content hover:bg-primary-600 shadow-lg shadow-primary/30",
secondary:
"bg-secondary text-secondary-content hover:bg-secondary-600 border border-secondary-600",
ghost: "hover:bg-surface-800 text-text-muted hover:text-text-main",
destructive:
"bg-danger hover:bg-red-600 text-white shadow-lg shadow-danger/30",
};
const sizes = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
</script>
<button
{type}
class="inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 active:scale-95 disabled:opacity-50 disabled:pointer-events-none {variants[
variant
]} {sizes[size]} {className}"
{disabled}
{onclick}
>
{@render children()}
</button>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
children: import("svelte").Snippet;
class?: string;
}
let { children, class: className = "" }: Props = $props();
</script>
<div class="glass-card p-6 border border-white/5 bg-slate-900/50 {className}">
{@render children()}
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
interface Props extends HTMLInputAttributes {
label?: string;
class?: string;
error?: string;
}
let {
label,
value = $bindable(),
class: className = "",
error,
...rest
}: Props = $props();
</script>
<div class={className}>
<label class="space-y-2 block">
{#if label}
<span
class="block text-xs font-medium text-text-muted uppercase tracking-wider"
>
{label}
</span>
{/if}
<input
class="w-full rounded-lg bg-surface-800/50 border border-surface-700 px-4 py-2.5 text-sm text-text-main placeholder-text-muted focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none transition-all duration-200 hover:border-surface-600"
bind:value
{...rest}
/>
</label>
{#if error}
<p class="text-xs text-red-500 mt-2">{error}</p>
{/if}
</div>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
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;
}
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>
<!-- 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>
<!-- Content -->
<div class="p-6">
{@render children()}
</div>
</div>
</div>
{/if}

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,58 @@
import mongoose from 'mongoose';
const printJobSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
spool_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Spool',
required: true
},
printer_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Printer',
required: true
},
name: {
type: String,
required: true,
default: 'Untitled Print'
},
duration_minutes: {
type: Number,
required: true
},
filament_used_g: {
type: Number,
required: true
},
filament_used_m: {
type: Number
},
calculated_cost_filament: {
type: Number
},
calculated_cost_energy: {
type: Number
},
status: {
type: String,
enum: ['In Progress', 'Success', 'Fail', 'Cancelled'],
default: 'Success'
},
started_at: {
type: Date
},
notes: String,
date: {
type: Date,
default: Date.now
}
}, {
timestamps: true
});
export const PrintJob = mongoose.models.PrintJob || mongoose.model('PrintJob', printJobSchema);

34
src/lib/models/Printer.ts Normal file
View File

@@ -0,0 +1,34 @@
import mongoose from 'mongoose';
const printerSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
name: {
type: String,
required: true,
trim: true
},
model: {
type: String,
trim: true
},
nozzle_diameter_mm: {
type: Number,
default: 0.4
},
power_consumption_watts: {
type: Number,
default: 0 // Used for energy calculation
},
bed_size_x_mm: Number,
bed_size_y_mm: Number,
bed_size_z_mm: Number,
image_url: String, // Optional URL for printer image
}, {
timestamps: true
});
export const Printer = mongoose.models.Printer || mongoose.model('Printer', printerSchema);

51
src/lib/models/Spool.ts Normal file
View File

@@ -0,0 +1,51 @@
import mongoose from 'mongoose';
const spoolSchema = new mongoose.Schema({
user_id: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
brand: {
type: String,
required: true,
trim: true
},
material: {
type: String,
required: true,
enum: ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'Nylon', 'PC', 'Other'],
default: 'PLA'
},
color_hex: {
type: String,
default: '#ffffff'
},
weight_initial_g: {
type: Number,
required: true,
min: 0
},
weight_remaining_g: {
type: Number,
required: true,
min: 0
},
price: {
type: Number,
min: 0
},
purchased_at: {
type: Date,
default: Date.now
},
is_active: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
// Check if model already exists to prevent overwrite error in HMR/dev mode
export const Spool = mongoose.models.Spool || mongoose.model('Spool', spoolSchema);

37
src/lib/models/User.ts Normal file
View File

@@ -0,0 +1,37 @@
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3
},
password: {
type: String,
required: true
},
role: {
type: String,
default: 'Maker'
},
location: {
type: String,
default: ''
},
electricity_rate: {
type: Number,
default: 0.12 // Default rate in $/kWh
},
currency: {
type: String,
default: 'USD'
},
createdAt: {
type: Date,
default: Date.now
}
});
export const User = mongoose.models.User || mongoose.model('User', userSchema);

17
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import mongoose from 'mongoose';
import { env } from '$env/dynamic/private';
const MONGODB_URI = env.MONGODB_URI || 'mongodb://localhost:27017/filaprint';
export const connectDB = async () => {
if (mongoose.connection.readyState >= 1) {
return;
}
try {
await mongoose.connect(MONGODB_URI);
console.log('MongoDB connected successfully via SvelteKit');
} catch (error) {
console.error('MongoDB connection error:', error);
}
};

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};

21
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,21 @@
<script>
import "../app.css";
import Navbar from "$lib/components/Navbar.svelte";
import favicon from "$lib/assets/favicon.svg";
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div
class="min-h-screen bg-background text-text-main bg-[radial-gradient(ellipse_at_top,var(--tw-gradient-stops))] from-surface-900 via-background to-background"
>
<Navbar />
<main class="md:pl-64 min-h-screen transition-all duration-300">
<div class="max-w-7xl mx-auto p-4 md:p-8 pt-20 md:pt-8 w-full">
{@render children()}
</div>
</main>
</div>

View File

@@ -0,0 +1,65 @@
import { Spool } from '$lib/models/Spool';
import { Printer } from '$lib/models/Printer';
import { PrintJob } from '$lib/models/PrintJob';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types';
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([
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(),
Printer.findOne({ user_id: userId }).lean(),
PrintJob.findOne({ user_id: userId, status: 'In Progress' }).populate('printer_id', 'name').populate('spool_id', 'brand color_hex material').lean(),
PrintJob.find({ user_id: userId, status: { $ne: 'In Progress' } }).select('calculated_cost_filament').lean()
]);
// Calculate totals
let totalWeightG = 0;
let totalValue = 0;
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;
totalValue += (ratio * spool.price);
}
});
// Calculate total spent on prints
let totalSpent = 0;
completedPrints.forEach(print => {
totalSpent += (print.calculated_cost_filament || 0);
});
// Keep grams for precision, format for display
const totalWeightKg = totalWeightG >= 1000
? (totalWeightG / 1000).toFixed(2)
: (totalWeightG / 1000).toFixed(3);
const estimatedValue = totalValue.toFixed(2);
return {
stats: {
spoolCount,
totalWeightKg,
totalWeightG,
printerCount,
estimatedValue,
totalSpent: totalSpent.toFixed(2)
},
recentPrints: JSON.parse(JSON.stringify(recentPrints)),
activePrinter: activePrinter ? JSON.parse(JSON.stringify(activePrinter)) : null,
activePrintJob: activePrintJob ? JSON.parse(JSON.stringify(activePrintJob)) : null
};
};

426
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,426 @@
<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";
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);
// Timer state
let currentTime = $state(Date.now());
let timerInterval: ReturnType<typeof setInterval>;
let notificationSent = $state(false);
onMount(() => {
// Request notification permission
if (
browser &&
"Notification" in window &&
Notification.permission === "default"
) {
Notification.requestPermission();
}
// Update timer every 10 seconds
timerInterval = setInterval(() => {
currentTime = Date.now();
// Check if print is complete
if (activePrintJob && !notificationSent) {
const progress = getProgress(
activePrintJob.started_at,
activePrintJob.duration_minutes
);
if (progress >= 100) {
sendNotification();
notificationSent = true;
}
}
}, 10000);
});
onDestroy(() => {
if (timerInterval) clearInterval(timerInterval);
});
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>
<!-- 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-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-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>
{#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
{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>
<!-- 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>
<!-- 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>
<!-- 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>
<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);
}
}
</style>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'Admin') {
throw redirect(303, '/');
}
return {};
};

View File

@@ -0,0 +1,11 @@
import { User } from '$lib/models/User';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
await connectDB();
const users = await User.find({}).sort({ createdAt: -1 }).lean();
return {
users: JSON.parse(JSON.stringify(users))
};
};

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import Card from "$lib/components/ui/Card.svelte";
let { data } = $props();
// svelte-ignore non_reactive_update
let users = $derived(data.users || []);
</script>
<div class="space-y-6 fade-in">
<div>
<h1 class="text-3xl font-bold text-white">User Management</h1>
<p class="text-text-muted mt-1">Manage platform access</p>
</div>
<Card class="overflow-x-auto">
<table class="w-full text-left">
<thead
class="text-xs text-text-muted uppercase border-b border-surface-700/50"
>
<tr>
<th class="px-4 py-3 font-medium">Username</th>
<th class="px-4 py-3 font-medium">Role</th>
<th class="px-4 py-3 font-medium">Joined</th>
<th class="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-700/50">
{#each users as user}
<tr
class="text-sm font-medium hover:bg-surface-800/50 transition-colors"
>
<td class="px-4 py-3 text-white">{user.username}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{user.role === 'Admin'
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-surface-700 text-text-muted'}"
>
{user.role}
</span>
</td>
<td class="px-4 py-3 text-text-muted"
>{new Date(user.createdAt).toLocaleDateString()}</td
>
<td class="px-4 py-3 text-right">
{#if user.role !== "Admin"}
<button
class="text-red-400 hover:text-red-300 transition-colors text-xs"
>Delete</button
>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</Card>
</div>

View File

@@ -0,0 +1,65 @@
import { PrintJob } from '$lib/models/PrintJob';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
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')
.sort({ date: 1 })
.lean();
// 1. Success vs Fail
let successCount = 0;
let failCount = 0;
// 2. Material Usage (Map: Material -> Weight)
const materialUsage: Record<string, number> = {};
// 3. Usage Over Time (Last 30 days)
const usageByDate: Record<string, number> = {};
// 4. Electricity Usage Over Time (Wh)
const electricityByDate: Record<string, number> = {};
let totalElectricity = 0;
prints.forEach(print => {
// Status
if (print.status === 'Success') successCount++;
else if (print.status === 'Fail') failCount++;
// Material
if (print.spool_id?.material) {
const mat = print.spool_id.material;
materialUsage[mat] = (materialUsage[mat] || 0) + print.filament_used_g;
}
// Timeline - Filament
const dateKey = new Date(print.date).toISOString().split('T')[0];
usageByDate[dateKey] = (usageByDate[dateKey] || 0) + print.filament_used_g;
// Electricity: Power (W) × Duration (hours) = Wh
const powerWatts = print.printer_id?.power_consumption_watts || 0;
const durationHours = (print.duration_minutes || 0) / 60;
const wattHours = powerWatts * durationHours;
electricityByDate[dateKey] = (electricityByDate[dateKey] || 0) + wattHours;
totalElectricity += wattHours;
});
return {
analytics: {
successRate: { success: successCount, fail: failCount },
materialUsage,
usageByDate,
electricityByDate,
totalElectricity: (totalElectricity / 1000).toFixed(2) // Convert to kWh
}
};
};

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import { onMount } from "svelte";
import Chart from "chart.js/auto";
import Card from "$lib/components/ui/Card.svelte";
import Icon from "@iconify/svelte";
let { data } = $props();
// svelte-ignore non_reactive_update
let analytics = $derived(data.analytics);
let timelineCanvas: HTMLCanvasElement;
let pieCanvas: HTMLCanvasElement;
let electricityCanvas: HTMLCanvasElement;
onMount(() => {
// 1. Timeline Chart - Filament Usage
const dates = Object.keys(analytics.usageByDate).slice(-30);
const weights = dates.map((d) => analytics.usageByDate[d]);
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 } },
},
},
});
// 2. Material Pie Chart
const materials = Object.keys(analytics.materialUsage);
const matWeights = materials.map((m) => analytics.materialUsage[m]);
const chartColors = [
"#3b82f6",
"#8b5cf6",
"#10b981",
"#f59e0b",
"#ef4444",
"#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" } },
},
},
});
// 3. Electricity Usage Chart
const electricDates = Object.keys(analytics.electricityByDate).slice(
-30,
);
const electricityWh = electricDates.map(
(d) => analytics.electricityByDate[d] / 1000,
); // Convert to kWh
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 },
},
scales: {
y: {
grid: { color: "rgba(255,255,255,0.1)" },
title: { display: true, text: "kWh", color: "#94a3b8" },
},
x: { grid: { display: false } },
},
},
});
});
let totalPrints = $derived(
analytics.successRate.success + analytics.successRate.fail,
);
let successRate = $derived(
totalPrints > 0
? Math.round((analytics.successRate.success / totalPrints) * 100)
: 0,
);
</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>
<!-- Stats Row -->
<div class="grid grid-cols-2 md:grid-cols-4 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>
</Card>
<Card class="text-center">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Success Rate
</p>
<p class="text-3xl font-bold text-emerald-400 mt-1">
{successRate}%
</p>
</Card>
<Card class="text-center">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Electricity Used
</p>
<p class="text-3xl font-bold text-amber-400 mt-1">
{analytics.totalElectricity}<span
class="text-sm font-normal text-slate-500 ml-1">kWh</span
>
</p>
</Card>
<Card class="text-center">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Materials
</p>
<p class="text-3xl font-bold text-violet-400 mt-1">
{Object.keys(analytics.materialUsage).length}
</p>
</Card>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Usage Timeline -->
<Card class="col-span-1 md:col-span-2 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>
</div>
<div class="h-[280px]">
<canvas bind:this={timelineCanvas}></canvas>
</div>
</Card>
<!-- Electricity Usage Chart -->
<Card class="col-span-1 md:col-span-2 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)
</h3>
</div>
<div class="h-[280px]">
<canvas bind:this={electricityCanvas}></canvas>
</div>
</Card>
<!-- Success Rate Stat -->
<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">
<circle
cx="64"
cy="64"
r="56"
stroke="currentColor"
stroke-width="12"
fill="transparent"
class="text-slate-800"
/>
<circle
cx="64"
cy="64"
r="56"
stroke="currentColor"
stroke-width="12"
fill="transparent"
stroke-dasharray={351.86}
stroke-dashoffset={351.86 -
(351.86 * successRate) / 100}
class="text-emerald-500 transition-all duration-1000"
/>
</svg>
<div
class="absolute inset-0 flex items-center justify-center flex-col"
>
<span class="text-3xl font-bold text-white"
>{successRate}%</span
>
<span class="text-xs text-slate-400 uppercase">Success</span
>
</div>
</div>
<p class="text-slate-400 mt-4 text-sm">
{analytics.successRate.success} Success / {analytics.successRate
.fail} Fail
</p>
</Card>
<!-- Material Usage Pie -->
<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>
</div>
<div class="h-[250px]">
<canvas bind:this={pieCanvas}></canvas>
</div>
</Card>
</div>
</div>
<style>
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { Spool } from '$lib/models/Spool';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
try {
const spools = await Spool.find({ is_active: true }).sort({ createdAt: -1 });
return json(spools);
} catch (error) {
return json({ error: 'Failed to fetch spools' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const data = await request.json();
// Basic validation could go here, but Mongoose validation handles most
const newSpool = await Spool.create(data);
return json(newSpool, { status: 201 });
} catch (error) {
console.error('Create Spool Error:', error);
return json({ error: 'Failed to create spool' }, { status: 400 });
}
};

3
src/routes/layout.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

View File

@@ -0,0 +1,49 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { User } from '$lib/models/User';
import { connectDB } from '$lib/server/db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_change_me';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) throw redirect(303, '/');
return {};
};
export const actions: Actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
if (!username || !password) {
return fail(400, { missing: true });
}
await connectDB();
const user = await User.findOne({ username });
if (!user) {
return fail(400, { invalid: true });
}
const validPass = await bcrypt.compare(password, user.password);
if (!validPass) {
return fail(400, { invalid: true });
}
const token = jwt.sign({ id: user._id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7 // 1 week
});
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Input from "$lib/components/ui/Input.svelte";
let { form } = $props();
let loading = $state(false);
</script>
<div class="min-h-screen flex items-center justify-center p-4">
<Card class="w-full max-w-md p-8 border-surface-700/50">
<div class="text-center mb-8">
<h1
class="text-3xl font-bold bg-linear-to-r from-primary to-accent bg-clip-text text-transparent"
>
Filaprint
</h1>
<p class="text-text-muted mt-2">Sign in to your dashboard</p>
</div>
{#if form?.invalid}
<div
class="bg-red-500/10 text-red-500 p-3 rounded-lg text-sm text-center mb-6 border border-red-500/20"
>
Invalid credentials. Please try again.
</div>
{/if}
<form
method="POST"
action="?/login"
use:enhance={() => {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}}
class="space-y-6"
>
<Input
name="username"
label="Username"
placeholder="Enter your username"
required
/>
<Input
name="password"
label="Password"
type="password"
placeholder="••••••••"
required
/>
<Button type="submit" class="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-text-muted">Don't have an account?</span>
<a
href="/register"
class="text-primary hover:text-primary-400 font-medium ml-1"
>Create one</a
>
</div>
</Card>
</div>

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies }) => {
cookies.delete('session', { path: '/' });
throw redirect(303, '/login');
};

View File

@@ -0,0 +1,94 @@
import { Printer } from '$lib/models/Printer';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
await connectDB();
const printers = await Printer.find({ user_id: locals.user.id }).sort({ name: 1 }).lean();
return {
printers: JSON.parse(JSON.stringify(printers))
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const name = formData.get('name');
const model = formData.get('model');
const watts = formData.get('power_consumption_watts');
const nozzle = formData.get('nozzle_diameter_mm');
if (!name) return fail(400, { missing: true });
await connectDB();
try {
await Printer.create({
user_id: locals.user.id,
name,
model,
power_consumption_watts: watts ? Number(watts) : 0,
nozzle_diameter_mm: nozzle ? Number(nozzle) : 0.4
});
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
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');
const model = formData.get('model');
const watts = formData.get('power_consumption_watts');
const nozzle = formData.get('nozzle_diameter_mm');
if (!id || !name) return fail(400, { missing: true });
await connectDB();
try {
await Printer.findOneAndUpdate(
{ _id: id, user_id: locals.user.id },
{
name,
model,
power_consumption_watts: watts ? Number(watts) : 0,
nozzle_diameter_mm: nozzle ? Number(nozzle) : 0.4
}
);
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
delete: 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 {
await Printer.findOneAndDelete({ _id: id, user_id: locals.user.id });
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
}
};

View File

@@ -0,0 +1,236 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import Input from "$lib/components/ui/Input.svelte";
import Icon from "@iconify/svelte";
let { data } = $props();
let showAddModal = $state(false);
let showEditModal = $state(false);
let isSubmitting = $state(false);
let editingPrinter: any = $state(null);
// svelte-ignore non_reactive_update
let printers = $derived(data.printers || []);
function openEditModal(printer: any) {
editingPrinter = { ...printer };
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingPrinter = null;
}
</script>
<div class="space-y-6 fade-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Printers</h1>
<p class="text-slate-400 mt-1">Configure your machines</p>
</div>
<Button onclick={() => (showAddModal = true)}>
<Icon icon="mdi:plus" class="w-4 h-4 mr-2" /> Add Printer
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each printers as printer}
<Card class="group hover:-translate-y-1 transition-all">
<div class="flex justify-between items-start">
<h3 class="text-lg font-bold text-white">{printer.name}</h3>
<span
class="text-xs font-mono bg-slate-800 px-2 py-1 rounded text-slate-300"
>
{printer.model || "Unknown Model"}
</span>
</div>
<div class="mt-4 space-y-2 text-sm text-slate-400">
<div class="flex justify-between border-b border-white/5 pb-1">
<span>Power</span>
<span class="text-white">{printer.power_consumption_watts}W</span>
</div>
<div class="flex justify-between border-b border-white/5 pb-1">
<span>Nozzle</span>
<span class="text-white">{printer.nozzle_diameter_mm}mm</span>
</div>
</div>
<div class="mt-6 flex gap-2">
<Button
size="sm"
variant="secondary"
class="w-full"
onclick={() => openEditModal(printer)}
>
<Icon icon="mdi:cog" class="w-4 h-4 mr-2" /> Configure
</Button>
</div>
</Card>
{/each}
{#if printers.length === 0}
<Card
class="col-span-full flex flex-col items-center justify-center py-12 border-dashed"
>
<div
class="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mb-4 text-slate-500"
>
<Icon icon="mdi:printer-3d" class="w-8 h-8" />
</div>
<h3 class="text-lg font-medium text-white">No printers yet</h3>
<p class="text-slate-500 mb-6 max-w-sm text-center">
Add your first 3D printer to start tracking prints.
</p>
<Button onclick={() => (showAddModal = true)} variant="secondary"
>Add First Printer</Button
>
</Card>
{/if}
</div>
</div>
<!-- Add Printer Modal -->
<Modal
title="Add New Printer"
open={showAddModal}
onclose={() => (showAddModal = false)}
>
<form
method="POST"
action="?/create"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
showAddModal = false;
};
}}
class="space-y-4"
>
<Input label="Printer Name" name="name" placeholder="My Ender 3" required />
<Input label="Model" name="model" placeholder="Creality Ender 3 V2" />
<div class="grid grid-cols-2 gap-4">
<Input
label="Power (Watts)"
name="power_consumption_watts"
type="number"
placeholder="350"
/>
<Input
label="Nozzle (mm)"
name="nozzle_diameter_mm"
type="number"
step="0.1"
value="0.4"
/>
</div>
<div class="pt-4 flex justify-end gap-3">
<Button
variant="ghost"
onclick={() => (showAddModal = false)}
type="button">Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Add Printer"}
</Button>
</div>
</form>
</Modal>
<!-- Edit Printer Modal -->
<Modal title="Configure Printer" open={showEditModal} onclose={closeEditModal}>
{#if editingPrinter}
<form
method="POST"
action="?/edit"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
closeEditModal();
};
}}
class="space-y-4"
>
<input type="hidden" name="id" value={editingPrinter._id} />
<Input
label="Printer Name"
name="name"
value={editingPrinter.name}
required
/>
<Input label="Model" name="model" value={editingPrinter.model} />
<div class="grid grid-cols-2 gap-4">
<Input
label="Power (Watts)"
name="power_consumption_watts"
type="number"
value={editingPrinter.power_consumption_watts}
/>
<Input
label="Nozzle (mm)"
name="nozzle_diameter_mm"
type="number"
step="0.1"
value={editingPrinter.nozzle_diameter_mm}
/>
</div>
<div class="pt-4 flex justify-between">
<Button
variant="destructive"
type="button"
disabled={isSubmitting}
onclick={async () => {
if (!confirm("Are you sure you want to delete this printer?"))
return;
isSubmitting = true;
const formData = new FormData();
formData.append("id", editingPrinter._id);
await fetch("?/delete", { method: "POST", body: formData });
isSubmitting = false;
closeEditModal();
window.location.reload();
}}
>
Delete
</Button>
<div class="flex gap-3">
<Button variant="ghost" onclick={closeEditModal} type="button"
>Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</form>
{/if}
</Modal>
<style>
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,203 @@
import { PrintJob } from '$lib/models/PrintJob';
import { Spool } from '$lib/models/Spool';
import { Printer } from '$lib/models/Printer';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
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 })
.lean();
// Fetch active spools and printers for the "Log Print" form - filtered by user
const [spools, printers] = await Promise.all([
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)),
printers: JSON.parse(JSON.stringify(printers))
};
};
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');
const printer_id = formData.get('printer_id');
const duration_minutes = formData.get('duration_minutes');
const filament_used_g = formData.get('filament_used_g');
const status = formData.get('status');
const manual_cost = formData.get('manual_cost');
const elapsed_minutes = formData.get('elapsed_minutes');
if (!spool_id || !printer_id || !filament_used_g) {
return fail(400, { missing: true });
}
await connectDB();
try {
// 1. Get the spool to calculate cost and deduct weight
const spool = await Spool.findOne({ _id: spool_id, user_id: locals.user.id });
if (!spool) return fail(404, { spoolNotFound: true });
// Verify printer belongs to user
const printer = await Printer.findOne({ _id: printer_id, user_id: locals.user.id });
if (!printer) return fail(404, { printerNotFound: true });
const weightUsed = Number(filament_used_g);
// Calculate Cost: use manual if provided, otherwise calculate
let costFilament: number;
if (manual_cost && String(manual_cost).trim() !== '') {
costFilament = Number(manual_cost);
} else {
costFilament = (spool.price / spool.weight_initial_g) * weightUsed;
}
// 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',
spool_id,
printer_id,
duration_minutes: Number(duration_minutes),
filament_used_g: weightUsed,
calculated_cost_filament: Number(costFilament.toFixed(2)),
status,
started_at: startedAt,
date: new Date()
});
// 3. Deduct Filament from Spool (only if not In Progress)
if (!isInProgress) {
spool.weight_remaining_g = Math.max(0, spool.weight_remaining_g - weightUsed);
await spool.save();
}
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
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');
const duration_minutes = formData.get('duration_minutes');
const filament_used_g = formData.get('filament_used_g');
const status = formData.get('status');
const manual_cost = formData.get('manual_cost');
const elapsed_minutes = formData.get('elapsed_minutes');
const printer_id = formData.get('printer_id');
const spool_id = formData.get('spool_id');
if (!id || !name) {
return fail(400, { missing: true });
}
await connectDB();
try {
const printJob = await PrintJob.findOne({ _id: id, user_id: locals.user.id }).populate('spool_id');
if (!printJob) return fail(404, { notFound: true });
const weightUsed = Number(filament_used_g);
// Calculate Cost: use manual if provided, otherwise calculate
let costFilament: number;
if (manual_cost && String(manual_cost).trim() !== '') {
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 {
costFilament = printJob.calculated_cost_filament || 0;
}
// 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);
} else if (!isInProgress) {
startedAt = null;
}
// Build update object
const updateData: any = {
name,
duration_minutes: Number(duration_minutes),
filament_used_g: weightUsed,
calculated_cost_filament: Number(costFilament.toFixed(2)),
status,
started_at: startedAt
};
// Update printer/spool if provided (for In Progress)
if (printer_id) {
updateData.printer_id = printer_id;
}
if (spool_id) {
updateData.spool_id = spool_id;
}
await PrintJob.findOneAndUpdate(
{ _id: id, user_id: locals.user.id },
updateData
);
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
delete: 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 {
await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id });
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
}
};

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Icon from "@iconify/svelte";
import LogPrintModal from "$lib/components/prints/LogPrintModal.svelte";
import EditPrintModal from "$lib/components/prints/EditPrintModal.svelte";
let { data } = $props();
let showLogModal = $state(false);
let showEditModal = $state(false);
let editingPrint: any = $state(null);
// svelte-ignore non_reactive_update
let prints = $derived(data.prints || []);
// svelte-ignore non_reactive_update
let spools = $derived(data.spools || []);
// svelte-ignore non_reactive_update
let printers = $derived(data.printers || []);
function openEditModal(print: any) {
editingPrint = { ...print };
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingPrint = null;
}
</script>
<div class="space-y-6 fade-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Print History</h1>
<p class="text-slate-400 mt-1">
Track your usage and successful prints
</p>
</div>
<Button onclick={() => (showLogModal = true)}>
<Icon icon="mdi:plus" class="w-4 h-4 mr-2" /> Log Print
</Button>
</div>
<div class="space-y-4">
{#each prints as print}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => openEditModal(print)} class="cursor-pointer">
<Card
class="flex flex-col md:flex-row items-start md:items-center gap-4 hover:bg-slate-800/80 transition-colors"
>
<!-- Status Icon -->
<div
class="w-10 h-10 rounded-full flex items-center justify-center shrink-0
{print.status === 'Success'
? 'bg-green-500/10 text-green-400'
: print.status === 'Fail'
? 'bg-red-500/10 text-red-400'
: print.status === 'In Progress'
? 'bg-blue-500/10 text-blue-400 animate-pulse'
: 'bg-slate-500/10 text-slate-400'}"
>
{#if print.status === "Success"}
<Icon icon="mdi:check-circle" class="w-5 h-5" />
{: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:cancel" class="w-5 h-5" />
{/if}
</div>
<div class="flex-1 min-w-0">
<h3 class="text-white font-medium truncate">
{print.name}
</h3>
<div
class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400"
>
<span class="flex items-center">
<span
class="w-2 h-2 rounded-full mr-1.5"
style="background-color: {print.spool_id
?.color_hex}"
></span>
{print.spool_id?.brand || "Unknown Spool"}
</span>
<span></span>
<span
>{print.printer_id?.name ||
"Unknown Printer"}</span
>
<span></span>
<span
>{new Date(
print.date,
).toLocaleDateString()}</span
>
</div>
</div>
<div class="flex items-center gap-6 text-sm text-slate-400">
<div class="text-right">
<p
class="text-xs uppercase tracking-wider text-slate-500"
>
Duration
</p>
<p class="text-white">{print.duration_minutes}m</p>
</div>
<div class="text-right">
<p
class="text-xs uppercase tracking-wider text-slate-500"
>
Weight
</p>
<p class="text-white">{print.filament_used_g}g</p>
</div>
<div class="text-right min-w-[60px]">
<p
class="text-xs uppercase tracking-wider text-slate-500"
>
Cost
</p>
<p class="text-white font-medium">
${print.calculated_cost_filament?.toFixed(2) ||
"0.00"}
</p>
</div>
</div>
</Card>
</div>
{/each}
{#if prints.length === 0}
<div class="text-center py-12 text-slate-500">
No prints logged yet. Start printing!
</div>
{/if}
</div>
</div>
<!-- Modals -->
<LogPrintModal
open={showLogModal}
{printers}
{spools}
onclose={() => (showLogModal = false)}
/>
<EditPrintModal
open={showEditModal}
print={editingPrint}
{printers}
{spools}
onclose={closeEditModal}
/>
<style>
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,71 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { User } from '$lib/models/User';
import { connectDB } from '$lib/server/db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret_change_me';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) throw redirect(303, '/');
return {};
};
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username') as string;
const password = data.get('password') as string;
const confirmPassword = data.get('confirmPassword') as string;
if (!username || !password || !confirmPassword) {
return fail(400, { missing: true });
}
if (password !== confirmPassword) {
return fail(400, { mismatch: true });
}
if (password.length < 6) {
return fail(400, { weak: true });
}
await connectDB();
const existing = await User.findOne({ username });
if (existing) {
return fail(400, { exists: true });
}
const hashedPassword = await bcrypt.hash(password, 10);
try {
// First user becomes Admin
const userCount = await User.countDocuments({});
const role = userCount === 0 ? 'Admin' : 'Maker';
const user = await User.create({
username,
password: hashedPassword,
role
});
const token = jwt.sign({ id: user._id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
cookies.set('session', token, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
});
} catch (error) {
console.error(error);
return fail(500, { error: true });
}
throw redirect(303, '/');
}
};

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Input from "$lib/components/ui/Input.svelte";
let { form } = $props();
let loading = $state(false);
</script>
<div class="min-h-screen flex items-center justify-center p-4">
<Card class="w-full max-w-md p-8 border-surface-700/50">
<div class="text-center mb-8">
<h1
class="text-3xl font-bold bg-linear-to-r from-primary to-accent bg-clip-text text-transparent"
>
Filaprint
</h1>
<p class="text-text-muted mt-2">Create your account</p>
</div>
{#if form?.mismatch}
<div
class="bg-red-500/10 text-red-500 p-3 rounded-lg text-sm text-center mb-6 border border-red-500/20"
>
Passwords do not match.
</div>
{:else if form?.exists}
<div
class="bg-red-500/10 text-red-500 p-3 rounded-lg text-sm text-center mb-6 border border-red-500/20"
>
Username already taken.
</div>
{:else if form?.weak}
<div
class="bg-red-500/10 text-red-500 p-3 rounded-lg text-sm text-center mb-6 border border-red-500/20"
>
Password must be at least 6 characters.
</div>
{/if}
<form
method="POST"
action="?/register"
use:enhance={() => {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}}
class="space-y-6"
>
<Input
name="username"
label="Username"
placeholder="Choose a username"
required
/>
<Input
name="password"
label="Password"
type="password"
placeholder="Min 6 characters"
required
/>
<Input
name="confirmPassword"
label="Confirm Password"
type="password"
placeholder="Confirm your password"
required
/>
<Button type="submit" class="w-full" disabled={loading}>
{loading ? "Creating Account..." : "Sign Up"}
</Button>
</form>
<div class="mt-6 text-center text-sm">
<span class="text-text-muted">Already have an account?</span>
<a
href="/login"
class="text-primary hover:text-primary-400 font-medium ml-1">Log In</a
>
</div>
</Card>
</div>

View File

@@ -0,0 +1,99 @@
import { connectDB } from '$lib/server/db';
import { User } from '$lib/models/User';
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import bcrypt from 'bcryptjs';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
await connectDB();
const user = await User.findById(locals.user.id).select('-password').lean();
return {
userProfile: JSON.parse(JSON.stringify(user))
};
};
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const username = formData.get('username');
const location = formData.get('location');
const electricity_rate = formData.get('electricity_rate');
if (!username) {
return fail(400, { missing: true, message: 'Username is required' });
}
await connectDB();
try {
// Check if username is taken by another user
const existingUser = await User.findOne({
username,
_id: { $ne: locals.user.id }
});
if (existingUser) {
return fail(400, { taken: true, message: 'Username already taken' });
}
await User.findByIdAndUpdate(locals.user.id, {
username,
location: location || '',
electricity_rate: electricity_rate ? Number(electricity_rate) : 0.12
});
return { success: true, message: 'Profile updated successfully' };
} catch (error) {
console.error(error);
return fail(500, { dbError: true, message: 'Failed to update profile' });
}
},
changePassword: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (!currentPassword || !newPassword || !confirmPassword) {
return fail(400, { missing: true, message: 'All password fields are required' });
}
if (newPassword !== confirmPassword) {
return fail(400, { mismatch: true, message: 'New passwords do not match' });
}
if (newPassword.length < 6) {
return fail(400, { weak: true, message: 'Password must be at least 6 characters' });
}
await connectDB();
try {
const user = await User.findById(locals.user.id);
if (!user) return fail(404, { notFound: true });
const validPassword = await bcrypt.compare(currentPassword, user.password);
if (!validPassword) {
return fail(400, { invalid: true, message: 'Current password is incorrect' });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
user.password = hashedPassword;
await user.save();
return { passwordChanged: true, message: 'Password changed successfully' };
} catch (error) {
console.error(error);
return fail(500, { dbError: true, message: 'Failed to change password' });
}
}
};

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Input from "$lib/components/ui/Input.svelte";
import Icon from "@iconify/svelte";
let { data, form } = $props();
let userProfile = $derived(data.userProfile);
let isSubmitting = $state(false);
let showSuccess = $state(false);
let successMessage = $state("");
$effect(() => {
if (form?.success || form?.passwordChanged) {
showSuccess = true;
successMessage = form.message || "Changes saved!";
setTimeout(() => {
showSuccess = false;
}, 3000);
}
});
</script>
<div class="space-y-6 fade-in max-w-2xl">
<div>
<h1 class="text-3xl font-bold text-white">Settings</h1>
<p class="text-slate-400 mt-1">Manage your account preferences</p>
</div>
{#if showSuccess}
<div
class="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 flex items-center gap-3"
>
<Icon icon="mdi:check-circle" class="w-5 h-5" />
{successMessage}
</div>
{/if}
{#if form?.message && !form?.success && !form?.passwordChanged}
<div
class="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 flex items-center gap-3"
>
<Icon icon="mdi:alert-circle" class="w-5 h-5" />
{form.message}
</div>
{/if}
<!-- Profile Section -->
<Card>
<div class="flex items-center gap-3 mb-6">
<Icon icon="mdi:account" class="w-6 h-6 text-primary" />
<h2 class="text-xl font-semibold text-white">Profile</h2>
</div>
<form
method="POST"
action="?/updateProfile"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="space-y-4"
>
<div class="flex items-center gap-4 mb-6">
<div
class="w-16 h-16 rounded-full bg-linear-to-br from-primary to-accent flex items-center justify-center text-2xl font-bold text-white"
>
{userProfile?.username?.charAt(0).toUpperCase() || "U"}
</div>
<div>
<p class="text-white font-medium">
{userProfile?.username}
</p>
<p class="text-sm text-slate-400">{userProfile?.role}</p>
</div>
</div>
<Input
label="Username"
name="username"
value={userProfile?.username}
required
/>
<Input
label="Location"
name="location"
value={userProfile?.location || ""}
placeholder="e.g., California, US or London, UK"
/>
<div class="grid grid-cols-2 gap-4">
<Input
label="Electricity Rate ($/kWh)"
name="electricity_rate"
type="number"
step="0.01"
value={userProfile?.electricity_rate || 0.12}
placeholder="0.12"
/>
<div class="space-y-2">
<p
class="text-xs font-medium text-slate-400 uppercase tracking-wider"
>
Rate Info
</p>
<p class="text-xs text-slate-500 mt-2">
Used to calculate electricity costs for your prints.
Check your utility bill for your rate.
</p>
</div>
</div>
<div class="pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</Card>
<!-- Password Section -->
<Card>
<div class="flex items-center gap-3 mb-6">
<Icon icon="mdi:lock" class="w-6 h-6 text-amber-400" />
<h2 class="text-xl font-semibold text-white">Change Password</h2>
</div>
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
};
}}
class="space-y-4"
>
<Input
label="Current Password"
name="currentPassword"
type="password"
required
/>
<div class="grid grid-cols-2 gap-4">
<Input
label="New Password"
name="newPassword"
type="password"
required
/>
<Input
label="Confirm New Password"
name="confirmPassword"
type="password"
required
/>
</div>
<div class="pt-2">
<Button
type="submit"
variant="secondary"
disabled={isSubmitting}
>
{isSubmitting ? "Changing..." : "Change Password"}
</Button>
</div>
</form>
</Card>
<!-- Danger Zone -->
<Card class="border-red-500/20">
<div class="flex items-center gap-3 mb-4">
<Icon icon="mdi:alert" class="w-6 h-6 text-red-400" />
<h2 class="text-xl font-semibold text-red-400">Danger Zone</h2>
</div>
<p class="text-slate-400 text-sm mb-4">
Once you delete your account, there is no going back. Please be
certain.
</p>
<Button
variant="destructive"
onclick={() => alert("Account deletion is not yet implemented")}
>
Delete Account
</Button>
</Card>
</div>
<style>
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,114 @@
import { Spool } from '$lib/models/Spool';
import { connectDB } from '$lib/server/db';
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
await connectDB();
// Filter by user_id
const spools = await Spool.find({ user_id: locals.user.id, is_active: true }).sort({ createdAt: -1 }).lean();
return {
spools: JSON.parse(JSON.stringify(spools))
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const brand = formData.get('brand');
const material = formData.get('material');
const color_hex = formData.get('color_hex');
const weight_initial_g = formData.get('weight_initial_g');
const weight_remaining_g = formData.get('weight_remaining_g');
const price = formData.get('price');
if (!brand || !material || !weight_initial_g) {
return fail(400, { missing: true });
}
await connectDB();
try {
await Spool.create({
user_id: locals.user.id,
brand,
material,
color_hex,
weight_initial_g: Number(weight_initial_g),
weight_remaining_g: Number(weight_remaining_g || weight_initial_g),
price: price ? Number(price) : 0
});
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
edit: async ({ request, locals }) => {
if (!locals.user) return fail(401, { unauthorized: true });
const formData = await request.formData();
const id = formData.get('id');
const brand = formData.get('brand');
const material = formData.get('material');
const color_hex = formData.get('color_hex');
const weight_initial_g = formData.get('weight_initial_g');
const weight_remaining_g = formData.get('weight_remaining_g');
const price = formData.get('price');
if (!id || !brand || !material || !weight_initial_g) {
return fail(400, { missing: true });
}
await connectDB();
try {
const result = await Spool.findOneAndUpdate(
{ _id: id, user_id: locals.user.id },
{
brand,
material,
color_hex,
weight_initial_g: Number(weight_initial_g),
weight_remaining_g: Number(weight_remaining_g),
price: price ? Number(price) : 0
}
);
if (!result) return fail(404, { notFound: true });
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
},
delete: 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 {
// Soft delete by setting is_active to false
const result = await Spool.findOneAndUpdate(
{ _id: id, user_id: locals.user.id },
{ is_active: false }
);
if (!result) return fail(404, { notFound: true });
return { success: true };
} catch (error) {
console.error(error);
return fail(500, { dbError: true });
}
}
};

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import { enhance } from "$app/forms";
import Button from "$lib/components/ui/Button.svelte";
import Card from "$lib/components/ui/Card.svelte";
import Modal from "$lib/components/ui/Modal.svelte";
import Input from "$lib/components/ui/Input.svelte";
import Icon from "@iconify/svelte";
let { data } = $props();
let showAddModal = $state(false);
let showEditModal = $state(false);
let isSubmitting = $state(false);
// Form state for add
let formColor = $state("#3b82f6");
let formInitial = $state(1000);
let formRemaining = $state(1000);
// Edit state
let editingSpool: any = $state(null);
// svelte-ignore non_reactive_update
let spools = $derived(data.spools || []);
function openEditModal(spool: any) {
editingSpool = { ...spool };
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingSpool = null;
}
</script>
<div class="space-y-6 fade-in">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">My Spools</h1>
<p class="text-slate-400 mt-1">Manage your filament inventory</p>
</div>
<Button onclick={() => (showAddModal = true)}>
<Icon icon="mdi:plus" class="w-4 h-4 mr-2" /> Add Spool
</Button>
</div>
{#if spools.length === 0}
<Card class="flex flex-col items-center justify-center py-12 border-dashed">
<div
class="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mb-4 text-slate-500"
>
<Icon icon="mdi:shape-outline" class="w-8 h-8" />
</div>
<h3 class="text-lg font-medium text-white">No spools yet</h3>
<p class="text-slate-500 mb-6 max-w-sm text-center">
Add your first filament spool to start tracking usage and costs.
</p>
<Button onclick={() => (showAddModal = true)} variant="secondary"
>Add First Spool</Button
>
</Card>
{:else}
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{#each spools as spool}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => openEditModal(spool)} class="cursor-pointer">
<Card
class="group relative overflow-hidden transition-all hover:-translate-y-1"
>
<!-- Color Strip -->
<div
class="absolute top-0 left-0 w-1.5 h-full"
style="background-color: {spool.color_hex}; box-shadow: 0 0 10px {spool.color_hex}40;"
></div>
<div class="pl-4">
<div class="flex justify-between items-start mb-2">
<div>
<span
class="text-xs font-bold px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700"
>{spool.material}</span
>
<h3
class="text-lg font-semibold text-white mt-2 leading-tight"
>
{spool.brand}
</h3>
</div>
<div
class="w-8 h-8 rounded-full border border-white/10"
style="background-color: {spool.color_hex}"
></div>
</div>
<div class="mt-4 space-y-2">
<div class="flex justify-between text-sm text-slate-400">
<span>Remaining</span>
<span class="text-white font-medium"
>{spool.weight_remaining_g}g</span
>
</div>
<!-- Progress Bar -->
<div class="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
style="width: {(spool.weight_remaining_g /
spool.weight_initial_g) *
100}%; background-color: {spool.color_hex};"
></div>
</div>
<div class="flex justify-between text-xs text-slate-500 pt-1">
<span>{spool.weight_initial_g}g Initial</span>
<span>${spool.price}</span>
</div>
</div>
</div>
</Card>
</div>
{/each}
</div>
{/if}
</div>
<!-- Add Modal -->
<Modal
title="Add New Spool"
open={showAddModal}
onclose={() => (showAddModal = false)}
>
<form
method="POST"
action="?/create"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
showAddModal = false;
};
}}
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4">
<Input label="Brand" name="brand" placeholder="e.g. Prusament" required />
<label class="space-y-2 block">
<span
class="block text-xs font-medium text-text-muted uppercase tracking-wider"
>Material</span
>
<select
name="material"
class="w-full rounded-lg bg-surface-800/50 border border-surface-700 px-4 py-2.5 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="ASA">ASA</option>
<option value="Other">Other</option>
</select>
</label>
</div>
<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-text-muted uppercase tracking-wider"
>Color</label
>
<div
class="relative h-[42px] w-full rounded-lg bg-surface-800/50 border border-surface-700 flex items-center px-2 hover:border-surface-600 transition-colors"
>
<input
name="color_hex"
type="color"
bind:value={formColor}
class="opacity-0 absolute inset-0 w-full h-full cursor-pointer z-10"
/>
<div
class="w-full h-4 rounded"
style="background-color: {formColor}; transition: background-color 0.2s;"
></div>
</div>
</div>
<Input
label="Price ($)"
name="price"
type="number"
step="0.01"
placeholder="0.00"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Initial (g)"
name="weight_initial_g"
type="number"
bind:value={formInitial}
required
/>
<Input
label="Current (g)"
name="weight_remaining_g"
type="number"
bind:value={formRemaining}
/>
</div>
<div class="pt-4 flex justify-end gap-3">
<Button
variant="ghost"
onclick={() => (showAddModal = false)}
type="button">Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Spool"}
</Button>
</div>
</form>
</Modal>
<!-- Edit Modal -->
<Modal title="Edit Spool" open={showEditModal} onclose={closeEditModal}>
{#if editingSpool}
<form
method="POST"
action="?/edit"
use:enhance={() => {
isSubmitting = true;
return async ({ update }) => {
await update();
isSubmitting = false;
closeEditModal();
};
}}
class="space-y-4"
>
<input type="hidden" name="id" value={editingSpool._id} />
<div class="grid grid-cols-2 gap-4">
<Input label="Brand" name="brand" value={editingSpool.brand} required />
<label class="space-y-2 block">
<span
class="block text-xs font-medium text-text-muted uppercase tracking-wider"
>Material</span
>
<select
name="material"
value={editingSpool.material}
class="w-full rounded-lg bg-surface-800/50 border border-surface-700 px-4 py-2.5 text-sm text-text-main focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
>
<option value="PLA">PLA</option>
<option value="PETG">PETG</option>
<option value="ABS">ABS</option>
<option value="TPU">TPU</option>
<option value="ASA">ASA</option>
<option value="Other">Other</option>
</select>
</label>
</div>
<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-text-muted uppercase tracking-wider"
>Color</label
>
<div
class="relative h-[42px] w-full rounded-lg bg-surface-800/50 border border-surface-700 flex items-center px-2 hover:border-surface-600 transition-colors"
>
<input
name="color_hex"
type="color"
bind:value={editingSpool.color_hex}
class="opacity-0 absolute inset-0 w-full h-full cursor-pointer z-10"
/>
<div
class="w-full h-4 rounded"
style="background-color: {editingSpool.color_hex}; transition: background-color 0.2s;"
></div>
</div>
</div>
<Input
label="Price ($)"
name="price"
type="number"
step="0.01"
value={editingSpool.price}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Initial (g)"
name="weight_initial_g"
type="number"
value={editingSpool.weight_initial_g}
required
/>
<Input
label="Current (g)"
name="weight_remaining_g"
type="number"
value={editingSpool.weight_remaining_g}
/>
</div>
<div class="pt-4 flex justify-between">
<Button
variant="destructive"
type="button"
disabled={isSubmitting}
onclick={async () => {
if (!confirm("Are you sure you want to delete this spool?")) return;
isSubmitting = true;
const formData = new FormData();
formData.append("id", editingSpool._id);
await fetch("?/delete", { method: "POST", body: formData });
isSubmitting = false;
closeEditModal();
window.location.reload();
}}
>
Delete
</Button>
<div class="flex gap-3">
<Button variant="ghost" onclick={closeEditModal} type="button"
>Cancel</Button
>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</form>
{/if}
</Modal>
<style>
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

Binary file not shown.

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

15
svelte.config.js Normal file
View File

@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
csrf: {
checkOrigin: true
}
}
};
export default config;

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });