diff --git a/src/app.css b/src/app.css index 4148d54..fd2d327 100644 --- a/src/app.css +++ b/src/app.css @@ -6,132 +6,155 @@ */ :root { - /* Spacing & Typography */ - --spacing: 0.25rem; - --text-scaling: 1.067; + /* Spacing & Typography */ + --spacing: 0.25rem; + --text-scaling: 1.067; - /* Font Family - JetBrains Mono */ - --base-font-family: "JetBrains Mono", system-ui, sans-serif; + /* Font Family - JetBrains Mono */ + --base-font-family: "JetBrains Mono", system-ui, sans-serif; - /* Primary Colors */ - --color-primary-50: oklch(0.92 0.04 257.51); - --color-primary-100: oklch(0.84 0.08 254.62); - --color-primary-200: oklch(0.77 0.11 254.28); - --color-primary-300: oklch(0.7 0.15 254.36); - --color-primary-400: oklch(0.63 0.19 255.71); - --color-primary-500: oklch(0.57 0.21 258.29); - --color-primary-600: oklch(0.52 0.19 258.15); - --color-primary-700: oklch(0.46 0.17 257.78); - --color-primary-800: oklch(0.4 0.14 257.62); - --color-primary-900: oklch(0.34 0.11 257.14); - --color-primary-950: oklch(0.28 0.08 257.49); + /* Primary Colors */ + --color-primary-50: oklch(0.92 0.04 257.51); + --color-primary-100: oklch(0.84 0.08 254.62); + --color-primary-200: oklch(0.77 0.11 254.28); + --color-primary-300: oklch(0.7 0.15 254.36); + --color-primary-400: oklch(0.63 0.19 255.71); + --color-primary-500: oklch(0.57 0.21 258.29); + --color-primary-600: oklch(0.52 0.19 258.15); + --color-primary-700: oklch(0.46 0.17 257.78); + --color-primary-800: oklch(0.4 0.14 257.62); + --color-primary-900: oklch(0.34 0.11 257.14); + --color-primary-950: oklch(0.28 0.08 257.49); - /* Secondary Colors */ - --color-secondary-50: oklch(0.87 0.05 300.12); - --color-secondary-100: oklch(0.79 0.09 303.55); - --color-secondary-200: oklch(0.7 0.13 304.43); - --color-secondary-300: oklch(0.63 0.17 303.8); - --color-secondary-400: oklch(0.55 0.2 302.74); - --color-secondary-500: oklch(0.49 0.23 300.45); - --color-secondary-600: oklch(0.45 0.21 299.59); - --color-secondary-700: oklch(0.42 0.19 298.25); - --color-secondary-800: oklch(0.38 0.17 296.27); - --color-secondary-900: oklch(0.34 0.15 293.96); - --color-secondary-950: oklch(0.3 0.13 291.15); + /* Secondary Colors */ + --color-secondary-50: oklch(0.87 0.05 300.12); + --color-secondary-100: oklch(0.79 0.09 303.55); + --color-secondary-200: oklch(0.7 0.13 304.43); + --color-secondary-300: oklch(0.63 0.17 303.8); + --color-secondary-400: oklch(0.55 0.2 302.74); + --color-secondary-500: oklch(0.49 0.23 300.45); + --color-secondary-600: oklch(0.45 0.21 299.59); + --color-secondary-700: oklch(0.42 0.19 298.25); + --color-secondary-800: oklch(0.38 0.17 296.27); + --color-secondary-900: oklch(0.34 0.15 293.96); + --color-secondary-950: oklch(0.3 0.13 291.15); - /* Tertiary Colors */ - --color-tertiary-50: oklch(0.91 0.08 328.89); - --color-tertiary-100: oklch(0.83 0.13 339.66); - --color-tertiary-200: oklch(0.76 0.18 345.54); - --color-tertiary-300: oklch(0.7 0.23 350.67); - --color-tertiary-400: oklch(0.66 0.25 355.84); - --color-tertiary-500: oklch(0.65 0.26 2.47); - --color-tertiary-600: oklch(0.59 0.24 1.69); - --color-tertiary-700: oklch(0.54 0.22 0.5); - --color-tertiary-800: oklch(0.48 0.2 359.65); - --color-tertiary-900: oklch(0.43 0.17 357.7); - --color-tertiary-950: oklch(0.37 0.15 355.33); + /* Tertiary Colors */ + --color-tertiary-50: oklch(0.91 0.08 328.89); + --color-tertiary-100: oklch(0.83 0.13 339.66); + --color-tertiary-200: oklch(0.76 0.18 345.54); + --color-tertiary-300: oklch(0.7 0.23 350.67); + --color-tertiary-400: oklch(0.66 0.25 355.84); + --color-tertiary-500: oklch(0.65 0.26 2.47); + --color-tertiary-600: oklch(0.59 0.24 1.69); + --color-tertiary-700: oklch(0.54 0.22 0.5); + --color-tertiary-800: oklch(0.48 0.2 359.65); + --color-tertiary-900: oklch(0.43 0.17 357.7); + --color-tertiary-950: oklch(0.37 0.15 355.33); - /* Success Colors */ - --color-success-50: oklch(0.94 0.09 178.68); - --color-success-500: oklch(0.83 0.13 174.96); - --color-success-950: oklch(0.27 0.04 185.3); + /* Success Colors */ + --color-success-50: oklch(0.94 0.09 178.68); + --color-success-500: oklch(0.83 0.13 174.96); + --color-success-950: oklch(0.27 0.04 185.3); - /* Warning Colors */ - --color-warning-50: oklch(0.96 0.05 84.57); - --color-warning-500: oklch(0.82 0.14 76.72); - --color-warning-950: oklch(0.52 0.13 51.44); + /* Warning Colors */ + --color-warning-50: oklch(0.96 0.05 84.57); + --color-warning-500: oklch(0.82 0.14 76.72); + --color-warning-950: oklch(0.52 0.13 51.44); - /* Error Colors */ - --color-error-50: oklch(0.9 0.04 14); - --color-error-500: oklch(0.64 0.22 28.71); - --color-error-950: oklch(0.42 0.17 29.23); + /* Error Colors */ + --color-error-50: oklch(0.9 0.04 14); + --color-error-500: oklch(0.64 0.22 28.71); + --color-error-950: oklch(0.42 0.17 29.23); - /* Surface Colors - Cerberus Exact */ - --color-surface-50: oklch(0.99 0 0); - --color-surface-100: oklch(0.91 0 0); - --color-surface-200: oklch(0.81 0 0); - --color-surface-300: oklch(0.72 0 0); - --color-surface-400: oklch(0.62 0 0); - --color-surface-500: oklch(0.51 0 0); - --color-surface-600: oklch(0.45 0 0); - --color-surface-700: oklch(0.39 0 0); - --color-surface-800: oklch(0.32 0 0); - --color-surface-900: oklch(0.25 0 0); - --color-surface-950: oklch(0.18 0 0); + /* Surface Colors - Cerberus Exact */ + --color-surface-50: oklch(0.99 0 0); + --color-surface-100: oklch(0.91 0 0); + --color-surface-200: oklch(0.81 0 0); + --color-surface-300: oklch(0.72 0 0); + --color-surface-400: oklch(0.62 0 0); + --color-surface-500: oklch(0.51 0 0); + --color-surface-600: oklch(0.45 0 0); + --color-surface-700: oklch(0.39 0 0); + --color-surface-800: oklch(0.32 0 0); + --color-surface-900: oklch(0.25 0 0); + --color-surface-950: oklch(0.18 0 0); - /* Semantic Colors */ - --base-font-color: var(--color-surface-950); - --base-font-color-dark: var(--color-surface-50); - --body-background-color: var(--color-surface-50); - --body-background-color-dark: var(--color-surface-950); + /* Semantic Colors */ + --base-font-color: var(--color-surface-950); + --base-font-color-dark: var(--color-surface-50); + --body-background-color: var(--color-surface-50); + --body-background-color-dark: var(--color-surface-950); } @font-face { - font-family: "JetBrains Mono"; - src: url("/font/JetBrainsMono-Regular.ttf") format("truetype"); - font-weight: normal; - font-style: normal; + font-family: "JetBrains Mono"; + src: url("/font/JetBrainsMono-Regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; } @theme { - --font-mono: "JetBrains Mono", monospace; - --font-display: "JetBrains Mono", monospace; - --font-body: "JetBrains Mono", monospace; + --font-mono: "JetBrains Mono", monospace; + --font-display: "JetBrains Mono", monospace; + --font-body: "JetBrains Mono", monospace; - /* Register Theme Colors for Tailwind */ - --color-background: var(--body-background-color-dark); - --color-surface: var(--color-surface-900); + /* Register Theme Colors for Tailwind */ + --color-background: var(--body-background-color-dark); + --color-surface: var(--color-surface-900); - --color-primary: var(--color-primary-500); - --color-secondary: var(--color-secondary-500); - --color-accent: var(--color-tertiary-500); + --color-primary: var(--color-primary-500); + --color-secondary: var(--color-secondary-500); + --color-accent: var(--color-tertiary-500); - --color-success: var(--color-success-500); - --color-warning: var(--color-warning-500); - --color-danger: var(--color-error-500); + --color-success: var(--color-success-500); + --color-warning: var(--color-warning-500); + --color-danger: var(--color-error-500); - --color-text-main: var(--base-font-color-dark); - --color-text-muted: var(--color-surface-400); + --color-text-main: var(--base-font-color-dark); + --color-text-muted: var(--color-surface-400); } body { - background-color: var(--body-background-color-dark); - color: var(--base-font-color-dark); - font-family: var(--base-font-family); - @apply antialiased min-h-screen selection:bg-primary selection:text-white; + background-color: var(--body-background-color-dark); + color: var(--base-font-color-dark); + font-family: var(--base-font-family); + @apply antialiased min-h-screen selection:bg-primary selection:text-white; } /* Card Utilities */ .glass-card { - background-color: var(--color-surface-900); - border: 1px solid var(--color-surface-700); - @apply rounded-xl shadow-lg transition-all duration-300; + background-color: var(--color-surface-900); + border: 1px solid var(--color-surface-700); + @apply rounded-xl shadow-lg transition-all duration-300; } .glass-card:hover { - background-color: var(--color-surface-800); - border-color: var(--color-primary-500); - transform: translateY(-2px); - box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5); + background-color: var(--color-surface-800); + border-color: var(--color-primary-500); + transform: translateY(-2px); + box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5); +} + +/* Date Input Dark Theme Styling */ +input[type="date"] { + color-scheme: dark; + appearance: none; + -webkit-appearance: none; +} + +input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.7); + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s; +} + +input[type="date"]::-webkit-calendar-picker-indicator:hover { + opacity: 1; +} + +/* Firefox date input styling */ +input[type="date"]::-moz-calendar-picker-indicator { + filter: invert(0.7); } diff --git a/src/lib/components/prints/EditPrintModal.svelte b/src/lib/components/prints/EditPrintModal.svelte index 6fbbb5c..2317e7c 100644 --- a/src/lib/components/prints/EditPrintModal.svelte +++ b/src/lib/components/prints/EditPrintModal.svelte @@ -47,6 +47,22 @@ window.location.reload(); } + async function handleDuplicate() { + if ( + !confirm( + "Create a duplicate print log? This will deduct filament from your spool.", + ) + ) + return; + isSubmitting = true; + const formData = new FormData(); + formData.append("id", print._id); + await fetch("?/duplicate", { method: "POST", body: formData }); + isSubmitting = false; + handleClose(); + window.location.reload(); + } + // STL Upload let stlFile = $state(null); let uploadProgress = $state(0); @@ -222,6 +238,28 @@ + +
+ + +
+ + +
+
+
@@ -494,6 +532,51 @@ {:else}
+ +
+
+ + + +
+
+ + + +
+
{/if} diff --git a/src/lib/components/prints/LogPrintModal.svelte b/src/lib/components/prints/LogPrintModal.svelte index 7274dc5..45c8379 100644 --- a/src/lib/components/prints/LogPrintModal.svelte +++ b/src/lib/components/prints/LogPrintModal.svelte @@ -192,6 +192,28 @@ required /> + +
+ + +
+ + +
+
+
@@ -457,10 +479,7 @@
{/if} -
- +
+ {/each} +
+ + +
- -
+ +

Total Prints

-

{totalPrints}

+

+ {analytics.totalPrints} +

Success Rate

-

+

{successRate}%

@@ -158,9 +362,31 @@

- Electricity Used + Total Spent

-

+

+ ${analytics.totalCost} +

+ + +

+ Filament Used +

+

+ {analytics.totalFilamentUsed}g +

+
+ +

+ Electricity +

+

{analytics.totalElectricity}kWh @@ -170,37 +396,118 @@

- Materials + Print Time

-

- {Object.keys(analytics.materialUsage).length} +

+ {formatTime(analytics.totalPrintTime)}

-
+ +
+ +
+ +
+
+

Avg Print Time

+

+ {formatTime(analytics.avgPrintTime)} +

+
+
+ +
+ +
+
+

Avg Cost/Print

+

${analytics.avgCost}

+
+
+ +
+ +
+
+

+ Avg Filament/Print +

+

+ {analytics.avgFilament}g +

+
+
+
+ + + +
+
+ +

Cost Breakdown

+
+
+
+
+

+ Filament Cost +

+

+ ${analytics.totalFilamentCost} +

+
+
+

Energy Cost

+

+ ${analytics.totalEnergyCost} +

+
+
+

Total Cost

+

+ ${analytics.totalCost} +

+
+
+
+ +
- +
- -

- Daily Filament Usage (g) -

+ +

Filament Usage

+ + +
+ +

Cost Over Time

+
+
+ +
+
+ - +

- Daily Electricity Usage (kWh) + Electricity Usage

@@ -208,7 +515,22 @@
- + + +
+ +

+ Material Distribution +

+
+
+ +
+
+
+ +
+
@@ -244,23 +566,86 @@ >
-

- {analytics.successRate.success} Success / {analytics.successRate - .fail} Fail -

+
+ + + {analytics.successRate.success} + + + + {analytics.successRate.fail} + + + + {analytics.successRate.cancelled} + +
- + + +
+ +

Printer Usage

+
+ {#if analytics.printerStats.length > 0} +
+ +
+ {:else} +
+ No printer data available +
+ {/if} +
+ +
- -

- Material Distribution -

+ +

3D Models

-
- +
+
+

+ {analytics.printsWithModels} +

+

+ Prints with Models +

+
+
+

+ {formatBytes(analytics.totalModelSize)} +

+

+ Storage Used +

+
+ {#if analytics.topModels.length > 0} +
+

+ Most Printed +

+ {#each analytics.topModels as model} +
+ {model.name} + {model.count}x +
+ {/each} +
+ {:else} +

+ No models uploaded yet +

+ {/if}
diff --git a/src/routes/library/+page.server.ts b/src/routes/library/+page.server.ts index c0f1285..5fd62ca 100644 --- a/src/routes/library/+page.server.ts +++ b/src/routes/library/+page.server.ts @@ -2,6 +2,8 @@ import { PrintJob } from '$lib/models/PrintJob'; import { connectDB } from '$lib/server/db'; import type { PageServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; +import { statSync, existsSync } from 'fs'; +import path from 'path'; export const load: PageServerLoad = async ({ locals }) => { if (!locals.user) throw redirect(303, '/login'); @@ -16,7 +18,31 @@ export const load: PageServerLoad = async ({ locals }) => { .sort({ date: -1 }) .lean(); + // Add file sizes to each model + const modelsWithSizes = printsWithSTL.map(print => { + let fileSize = 0; + if (print.stl_file) { + try { + const filePath = path.join('static', print.stl_file); + if (existsSync(filePath)) { + const stats = statSync(filePath); + fileSize = stats.size; + } + } catch (e) { + // Ignore file read errors + } + } + return { + ...print, + fileSize + }; + }); + + // Calculate total storage + const totalStorage = modelsWithSizes.reduce((sum, m) => sum + m.fileSize, 0); + return { - models: JSON.parse(JSON.stringify(printsWithSTL)) + models: JSON.parse(JSON.stringify(modelsWithSizes)), + totalStorage }; }; diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 0402fcb..b13c379 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -5,6 +5,7 @@ let { data } = $props(); let models = $derived(data.models); + let totalStorage = $derived(data.totalStorage); let selectedModel = $state(null); let showViewer = $state(false); @@ -26,14 +27,34 @@ year: "numeric", }); } + + function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; + }
-
-

Model Library

-

- Browse your 3D model collection ({models.length} models) -

+
+
+

Model Library

+

+ Browse your 3D model collection ({models.length} models) +

+
+
+ + {formatBytes(totalStorage)} used +
{#if models.length === 0} @@ -129,6 +150,13 @@ Failed {/if} + + + + {formatBytes(model.fileSize)} +
@@ -205,6 +233,12 @@ ${selectedModel.calculated_cost_filament.toFixed(2)}
{/if} + {#if selectedModel.fileSize} +
+ File Size: + {formatBytes(selectedModel.fileSize)} +
+ {/if}
{/if} diff --git a/src/routes/prints/+page.server.ts b/src/routes/prints/+page.server.ts index 0f1c951..f03b266 100644 --- a/src/routes/prints/+page.server.ts +++ b/src/routes/prints/+page.server.ts @@ -48,6 +48,7 @@ export const actions: Actions = { const manual_cost = formData.get('manual_cost'); const elapsed_minutes = formData.get('elapsed_minutes'); const stl_file = formData.get('stl_file'); + const date = formData.get('date'); if (!spool_id || !printer_id || !filament_used_g) { return fail(400, { missing: true }); @@ -115,7 +116,7 @@ export const actions: Actions = { status, started_at: startedAt, stl_file: stl_file || null, - date: new Date() + date: date ? new Date(date as string) : new Date() }); // 3. Deduct Filament from Spool (only if not In Progress) @@ -146,6 +147,7 @@ export const actions: Actions = { const spool_id = formData.get('spool_id'); const stl_file = formData.get('stl_file'); const remove_model = formData.get('remove_model'); + const date = formData.get('date'); if (!id || !name) { return fail(400, { missing: true }); @@ -169,13 +171,18 @@ export const actions: Actions = { ? await Printer.findById(printer_id) : printJob.printer_id; + // Get correct spool for cost calculation (use new spool if provided, else existing) + const spoolForCalc = spool_id + ? await Spool.findById(spool_id) + : printJob.spool_id; + // Calculate Filament Cost: use manual if provided, otherwise calculate let costFilament: number; if (manual_cost && String(manual_cost).trim() !== '') { // Manual cost is the total, we'll calculate energy separately for tracking costFilament = Number(manual_cost); - } else if (printJob.spool_id?.price && printJob.spool_id?.weight_initial_g) { - costFilament = (printJob.spool_id.price / printJob.spool_id.weight_initial_g) * weightUsed; + } else if (spoolForCalc?.price && spoolForCalc?.weight_initial_g) { + costFilament = (spoolForCalc.price / spoolForCalc.weight_initial_g) * weightUsed; } else { costFilament = 0; } @@ -212,10 +219,11 @@ export const actions: Actions = { calculated_cost_filament: Number(totalCost.toFixed(2)), calculated_cost_energy: Number(costEnergy.toFixed(2)), status, - started_at: startedAt + started_at: startedAt, + date: date ? new Date(date as string) : printJob.date }; - // Update printer/spool if provided (for In Progress) + // Update printer/spool if provided if (printer_id) { updateData.printer_id = printer_id; } @@ -288,6 +296,73 @@ export const actions: Actions = { } await PrintJob.findOneAndDelete({ _id: id, user_id: locals.user.id }); + return { success: true }; + } catch (error) { + console.error(error); + return fail(500, { dbError: true }); + } + }, + + duplicate: async ({ request, locals }) => { + if (!locals.user) return fail(401, { unauthorized: true }); + + const formData = await request.formData(); + const id = formData.get('id'); + + if (!id) return fail(400, { missing: true }); + + await connectDB(); + + try { + // Find the original print with populated spool and printer + const original = await PrintJob.findOne({ _id: id, user_id: locals.user.id }) + .populate('spool_id') + .populate('printer_id'); + if (!original) return fail(404, { notFound: true }); + + // Get user's electricity rate + const user = await User.findById(locals.user.id); + const electricityRate = user?.electricity_rate || 0.12; + + // Recalculate costs based on current spool prices + let costFilament = 0; + let costEnergy = 0; + const spool = original.spool_id as any; + const printer = original.printer_id as any; + + if (spool?.price && spool?.weight_initial_g && original.filament_used_g) { + costFilament = (spool.price / spool.weight_initial_g) * original.filament_used_g; + } + + if (printer?.power_consumption_watts && original.duration_minutes) { + const powerKw = printer.power_consumption_watts / 1000; + const durationHours = original.duration_minutes / 60; + costEnergy = powerKw * durationHours * electricityRate; + } + + const totalCost = costFilament + costEnergy; + + // Create a new print with the same details + await PrintJob.create({ + user_id: locals.user.id, + name: original.name, + printer_id: original.printer_id?._id || original.printer_id, + spool_id: original.spool_id?._id || original.spool_id, + duration_minutes: original.duration_minutes, + filament_used_g: original.filament_used_g, + calculated_cost_filament: Number(totalCost.toFixed(2)), + calculated_cost_energy: Number(costEnergy.toFixed(2)), + status: 'Success', + stl_file: original.stl_file, + date: new Date() + }); + + // Deduct filament from spool for the new print + if (spool && original.filament_used_g) { + spool.weight_remaining_g = Math.max(0, spool.weight_remaining_g - original.filament_used_g); + await spool.save(); + } + return { success: true }; } catch (error) { console.error(error);