STL Model Viewer
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ vite.config.ts.timestamp-*
|
||||
|
||||
.vscode/
|
||||
|
||||
static/uploads/
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{ name: "Spools", href: "/spools", icon: "mdi:cylinder" },
|
||||
{ name: "Printers", href: "/printers", icon: "mdi:printer-3d" },
|
||||
{ name: "Prints", href: "/prints", icon: "mdi:cube-outline" },
|
||||
{ name: "Library", href: "/library", icon: "mdi:cube-scan" },
|
||||
{ name: "Analytics", href: "/analytics", icon: "mdi:chart-line" },
|
||||
...($page.data.user?.role === "Admin"
|
||||
? [
|
||||
|
||||
216
src/lib/components/STLViewer.svelte
Normal file
216
src/lib/components/STLViewer.svelte
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import * as THREE from "three";
|
||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
||||
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
|
||||
interface Props {
|
||||
modelPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let { modelPath, width = 300, height = 200 }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
let controls: OrbitControls;
|
||||
let animationId: number;
|
||||
|
||||
onMount(() => {
|
||||
initScene();
|
||||
loadModel();
|
||||
animate();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
if (renderer) renderer.dispose();
|
||||
if (controls) controls.dispose();
|
||||
});
|
||||
|
||||
function initScene() {
|
||||
// Scene
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1e1e2e);
|
||||
|
||||
// Camera
|
||||
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
|
||||
camera.position.set(100, 100, 100);
|
||||
|
||||
// Renderer
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Controls - OrbitControls for intuitive 3D navigation
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.5;
|
||||
|
||||
// Zoom limits
|
||||
controls.minDistance = 20;
|
||||
controls.maxDistance = 300;
|
||||
|
||||
// Pan settings
|
||||
controls.enablePan = true;
|
||||
controls.panSpeed = 0.8;
|
||||
|
||||
// Touch support
|
||||
controls.touches = {
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||
};
|
||||
|
||||
// Stop auto-rotate on interaction
|
||||
controls.addEventListener("start", () => {
|
||||
controls.autoRotate = false;
|
||||
});
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0x404040, 2);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
|
||||
directionalLight.position.set(1, 1, 1);
|
||||
scene.add(directionalLight);
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
backLight.position.set(-1, -1, -1);
|
||||
scene.add(backLight);
|
||||
|
||||
// Grid helper
|
||||
const gridHelper = new THREE.GridHelper(100, 10, 0x444466, 0x333344);
|
||||
scene.add(gridHelper);
|
||||
}
|
||||
|
||||
function loadModel() {
|
||||
const extension = modelPath.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (extension === "obj") {
|
||||
loadOBJ();
|
||||
} else {
|
||||
loadSTL();
|
||||
}
|
||||
}
|
||||
|
||||
function loadSTL() {
|
||||
const loader = new STLLoader();
|
||||
|
||||
loader.load(
|
||||
modelPath,
|
||||
(geometry) => {
|
||||
addGeometryToScene(geometry);
|
||||
},
|
||||
undefined,
|
||||
(err) => {
|
||||
console.error("Error loading STL:", err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function loadOBJ() {
|
||||
const loader = new OBJLoader();
|
||||
|
||||
loader.load(
|
||||
modelPath,
|
||||
(obj) => {
|
||||
// Center the object
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
obj.position.sub(center);
|
||||
|
||||
// Scale to fit
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 50 / maxDim;
|
||||
obj.scale.set(scale, scale, scale);
|
||||
|
||||
// Apply material to all meshes
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x3b82f6,
|
||||
specular: 0x111111,
|
||||
shininess: 50,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
obj.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material = material;
|
||||
}
|
||||
});
|
||||
|
||||
obj.rotation.x = -Math.PI / 2;
|
||||
scene.add(obj);
|
||||
|
||||
// Position camera
|
||||
const distance = maxDim * scale * 2.5;
|
||||
camera.position.set(distance, distance, distance);
|
||||
controls.update();
|
||||
},
|
||||
undefined,
|
||||
(err) => {
|
||||
console.error("Error loading OBJ:", err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function addGeometryToScene(geometry: THREE.BufferGeometry) {
|
||||
// Center the geometry
|
||||
geometry.computeBoundingBox();
|
||||
const center = new THREE.Vector3();
|
||||
geometry.boundingBox!.getCenter(center);
|
||||
geometry.translate(-center.x, -center.y, -center.z);
|
||||
|
||||
// Scale to fit
|
||||
const size = new THREE.Vector3();
|
||||
geometry.boundingBox!.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
const scale = 50 / maxDim;
|
||||
|
||||
// Material
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x3b82f6,
|
||||
specular: 0x111111,
|
||||
shininess: 50,
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.scale.set(scale, scale, scale);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
scene.add(mesh);
|
||||
|
||||
// Position camera based on model size
|
||||
const distance = maxDim * scale * 2.5;
|
||||
camera.position.set(distance, distance, distance);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="model-viewer rounded-lg overflow-hidden"
|
||||
style="width: {width}px; height: {height}px;"
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.model-viewer {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.model-viewer :global(canvas) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from "$app/forms";
|
||||
import { browser } from "$app/environment";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import Input from "$lib/components/ui/Input.svelte";
|
||||
@@ -45,6 +46,63 @@
|
||||
handleClose();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// STL Upload
|
||||
let stlFile = $state<File | null>(null);
|
||||
let uploadProgress = $state(0);
|
||||
let uploadStatus = $state("");
|
||||
|
||||
function uploadSTL(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
if (!stlFile) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadStatus = "Uploading...";
|
||||
uploadProgress = 0;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("stl", stlFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status === 200) {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
uploadStatus = "Uploaded!";
|
||||
uploadProgress = 100;
|
||||
resolve(data.path);
|
||||
} else {
|
||||
uploadStatus = "Upload failed";
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
uploadStatus = "Upload error";
|
||||
resolve("");
|
||||
});
|
||||
|
||||
xhr.open("POST", "/api/upload-stl");
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
stlFile = input.files[0];
|
||||
uploadStatus = stlFile.name;
|
||||
uploadProgress = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Edit Print Log" {open} onclose={handleClose}>
|
||||
@@ -52,7 +110,17 @@
|
||||
<form
|
||||
method="POST"
|
||||
action="?/edit"
|
||||
use:enhance={({ formData }) => {
|
||||
use:enhance={async ({ formData }) => {
|
||||
isSubmitting = true;
|
||||
|
||||
// Upload STL file first if selected
|
||||
if (stlFile) {
|
||||
const uploadedPath = await uploadSTL();
|
||||
if (uploadedPath) {
|
||||
formData.set("stl_file", uploadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert hours + minutes to total minutes
|
||||
const hours = Number(formData.get("duration_hours") || 0);
|
||||
const mins = Number(formData.get("duration_mins") || 0);
|
||||
@@ -66,7 +134,6 @@
|
||||
String(elapsedHours * 60 + elapsedMins),
|
||||
);
|
||||
|
||||
isSubmitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
isSubmitting = false;
|
||||
@@ -146,6 +213,95 @@
|
||||
|
||||
<Input label="Print Name" name="name" value={print.name} required />
|
||||
|
||||
<!-- STL Viewer/Upload -->
|
||||
<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"
|
||||
>
|
||||
3D Model {print.stl_file ? "" : "(Optional)"}
|
||||
</label>
|
||||
|
||||
{#if print.stl_file && browser && !stlFile}
|
||||
<!-- Show existing STL viewer -->
|
||||
<div
|
||||
class="flex justify-center bg-slate-900 rounded-lg p-2"
|
||||
>
|
||||
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
|
||||
<STLViewer
|
||||
modelPath={print.stl_file}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 text-center">
|
||||
Click below to replace with a new model
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Upload button or progress -->
|
||||
{#if uploadStatus === "Uploading..." && uploadProgress > 0}
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-slate-400">{stlFile?.name}</span>
|
||||
<span class="text-blue-400">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full transition-all duration-150"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex-1 cursor-pointer">
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border border-dashed border-slate-600 hover:border-blue-500 hover:bg-blue-500/5 transition-all text-slate-400 hover:text-blue-400"
|
||||
>
|
||||
<Icon icon="mdi:file-upload" class="w-5 h-5" />
|
||||
<span class="text-sm">
|
||||
{#if uploadStatus === "Uploaded!"}
|
||||
<span class="text-green-400"
|
||||
>✓ {stlFile?.name}</span
|
||||
>
|
||||
{:else if uploadStatus}
|
||||
{uploadStatus}
|
||||
{:else if print.stl_file}
|
||||
Replace model file...
|
||||
{:else}
|
||||
Choose model file...
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".stl,.obj"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
</label>
|
||||
{#if stlFile}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
onclick={() => {
|
||||
stlFile = null;
|
||||
uploadProgress = 0;
|
||||
uploadStatus = "";
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" class="w-5 h-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editStatus === "In Progress"}
|
||||
<!-- In Progress specific fields -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -1,260 +1,473 @@
|
||||
<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";
|
||||
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;
|
||||
}
|
||||
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");
|
||||
let { open, printers, spools, onclose }: Props = $props();
|
||||
let isSubmitting = $state(false);
|
||||
let selectedStatus = $state("Success");
|
||||
let stlFile = $state<File | null>(null);
|
||||
let stlPath = $state("");
|
||||
let uploadProgress = $state(0);
|
||||
let uploadStatus = $state("");
|
||||
|
||||
function handleClose() {
|
||||
selectedStatus = "Success";
|
||||
onclose();
|
||||
}
|
||||
function uploadSTL(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
if (!stlFile) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadStatus = "Uploading...";
|
||||
uploadProgress = 0;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("stl", stlFile);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status === 200) {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
uploadStatus = "Uploaded!";
|
||||
uploadProgress = 100;
|
||||
resolve(data.path);
|
||||
} else {
|
||||
uploadStatus = "Upload failed";
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
uploadStatus = "Upload error";
|
||||
resolve("");
|
||||
});
|
||||
|
||||
xhr.open("POST", "/api/upload-stl");
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
stlFile = input.files[0];
|
||||
uploadStatus = stlFile.name;
|
||||
uploadProgress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
selectedStatus = "Success";
|
||||
stlFile = null;
|
||||
stlPath = "";
|
||||
uploadProgress = 0;
|
||||
uploadStatus = "";
|
||||
onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Log a Print" {open} onclose={handleClose}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/log"
|
||||
use:enhance={({ formData }) => {
|
||||
// Convert hours + minutes to total minutes
|
||||
const hours = Number(formData.get('duration_hours') || 0);
|
||||
const mins = Number(formData.get('duration_mins') || 0);
|
||||
formData.set('duration_minutes', String(hours * 60 + mins));
|
||||
<form
|
||||
method="POST"
|
||||
action="?/log"
|
||||
use:enhance={async ({ formData }) => {
|
||||
isSubmitting = true;
|
||||
|
||||
// Convert elapsed hours + minutes to total minutes
|
||||
const elapsedHours = Number(formData.get('elapsed_hours') || 0);
|
||||
const elapsedMins = Number(formData.get('elapsed_mins') || 0);
|
||||
formData.set('elapsed_minutes', String(elapsedHours * 60 + elapsedMins));
|
||||
// Upload STL file first if selected
|
||||
if (stlFile) {
|
||||
const uploadedPath = await uploadSTL();
|
||||
if (uploadedPath) {
|
||||
formData.set("stl_file", uploadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
// Convert hours + minutes to total minutes
|
||||
const hours = Number(formData.get("duration_hours") || 0);
|
||||
const mins = Number(formData.get("duration_mins") || 0);
|
||||
formData.set("duration_minutes", String(hours * 60 + mins));
|
||||
|
||||
<Input
|
||||
label="Print Name"
|
||||
name="name"
|
||||
placeholder="Dragon Scale Mail"
|
||||
required
|
||||
/>
|
||||
// Convert elapsed hours + minutes to total minutes
|
||||
const elapsedHours = Number(formData.get("elapsed_hours") || 0);
|
||||
const elapsedMins = Number(formData.get("elapsed_mins") || 0);
|
||||
formData.set(
|
||||
"elapsed_minutes",
|
||||
String(elapsedHours * 60 + elapsedMins),
|
||||
);
|
||||
|
||||
<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>
|
||||
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>
|
||||
|
||||
{#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="space-y-3">
|
||||
<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">Total Duration</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input type="number" name="duration_hours" placeholder="2" min="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="number" name="duration_mins" placeholder="30" min="0" max="59" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">Already Elapsed</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input type="number" name="elapsed_hours" placeholder="0" min="0" value="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="number" name="elapsed_mins" placeholder="0" min="0" max="59" value="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="space-y-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">Duration</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input type="number" name="duration_hours" placeholder="2" min="0" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">hr</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input type="number" name="duration_mins" placeholder="30" min="0" max="59" class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10" />
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
<Input
|
||||
label="Print Name"
|
||||
name="name"
|
||||
placeholder="Dragon Scale Mail"
|
||||
required
|
||||
/>
|
||||
|
||||
<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>
|
||||
<!-- 3D Model Upload -->
|
||||
<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"
|
||||
>
|
||||
3D Model (Optional)
|
||||
</label>
|
||||
|
||||
{#if uploadStatus === "Uploading..." && uploadProgress > 0}
|
||||
<!-- Progress Bar -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-slate-400">{stlFile?.name}</span>
|
||||
<span class="text-blue-400">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full transition-all duration-150"
|
||||
style="width: {uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Upload Button -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex-1 cursor-pointer">
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border border-dashed border-slate-600 hover:border-blue-500 hover:bg-blue-500/5 transition-all text-slate-400 hover:text-blue-400"
|
||||
>
|
||||
<Icon icon="mdi:file-upload" class="w-5 h-5" />
|
||||
<span class="text-sm">
|
||||
{#if uploadStatus === "Uploaded!"}
|
||||
<span class="text-green-400"
|
||||
>✓ {stlFile?.name}</span
|
||||
>
|
||||
{:else if uploadStatus}
|
||||
{uploadStatus}
|
||||
{:else}
|
||||
Choose model file...
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".stl,.obj"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
</label>
|
||||
{#if stlFile}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
onclick={() => {
|
||||
stlFile = null;
|
||||
uploadProgress = 0;
|
||||
uploadStatus = "";
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" class="w-5 h-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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-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="space-y-3">
|
||||
<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"
|
||||
>Total Duration</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="duration_hours"
|
||||
placeholder="2"
|
||||
min="0"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>hr</span
|
||||
>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="duration_mins"
|
||||
placeholder="30"
|
||||
min="0"
|
||||
max="59"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>min</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
>Already Elapsed</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="elapsed_hours"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
value="0"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>hr</span
|
||||
>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="elapsed_mins"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
max="59"
|
||||
value="0"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>min</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="space-y-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"
|
||||
>Duration</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="duration_hours"
|
||||
placeholder="2"
|
||||
min="0"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-8"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>hr</span
|
||||
>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="number"
|
||||
name="duration_mins"
|
||||
placeholder="30"
|
||||
min="0"
|
||||
max="59"
|
||||
class="w-full rounded-lg bg-slate-800/50 border border-slate-700 px-4 py-2.5 text-sm text-slate-100 focus:border-blue-500 focus:outline-none pr-10"
|
||||
/>
|
||||
<span
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500"
|
||||
>min</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -47,6 +47,9 @@ const printJobSchema = new mongoose.Schema({
|
||||
type: Date
|
||||
},
|
||||
notes: String,
|
||||
stl_file: {
|
||||
type: String // Path to uploaded STL file
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
|
||||
54
src/routes/api/upload-stl/+server.ts
Normal file
54
src/routes/api/upload-stl/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const UPLOAD_DIR = 'static/uploads/models';
|
||||
const ALLOWED_EXTENSIONS = ['.stl', '.obj'];
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('stl') as File; // Keep 'stl' for backward compatibility
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
throw error(400, 'No file provided');
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = '.' + fileName.split('.').pop();
|
||||
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
throw error(400, 'Only STL and OBJ files are allowed');
|
||||
}
|
||||
|
||||
// Create upload directory if it doesn't exist
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
const uniqueFileName = `${locals.user.id}_${timestamp}_${sanitizedName}`;
|
||||
const filePath = path.join(UPLOAD_DIR, uniqueFileName);
|
||||
|
||||
// Write file to disk
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Return the public path
|
||||
const publicPath = `/uploads/models/${uniqueFileName}`;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
path: publicPath,
|
||||
fileName: file.name,
|
||||
fileType: extension.slice(1).toUpperCase()
|
||||
});
|
||||
};
|
||||
22
src/routes/library/+page.server.ts
Normal file
22
src/routes/library/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
|
||||
// Get all prints with STL files
|
||||
const printsWithSTL = await PrintJob.find({
|
||||
user_id: locals.user.id,
|
||||
stl_file: { $exists: true, $nin: [null, ''] }
|
||||
})
|
||||
.sort({ date: -1 })
|
||||
.lean();
|
||||
|
||||
return {
|
||||
models: JSON.parse(JSON.stringify(printsWithSTL))
|
||||
};
|
||||
};
|
||||
227
src/routes/library/+page.svelte
Normal file
227
src/routes/library/+page.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import Card from "$lib/components/ui/Card.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
let { data } = $props();
|
||||
let models = $derived(data.models);
|
||||
|
||||
let selectedModel = $state<any>(null);
|
||||
let showViewer = $state(false);
|
||||
|
||||
function openViewer(model: any) {
|
||||
selectedModel = model;
|
||||
showViewer = true;
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
selectedModel = null;
|
||||
showViewer = false;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 fade-in">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Model Library</h1>
|
||||
<p class="text-slate-400 mt-1">
|
||||
Browse your 3D model collection ({models.length} models)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if models.length === 0}
|
||||
<Card class="text-center py-12">
|
||||
<Icon
|
||||
icon="mdi:cube-off-outline"
|
||||
class="w-16 h-16 text-slate-600 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-medium text-slate-300 mb-2">
|
||||
No Models Yet
|
||||
</h3>
|
||||
<p class="text-slate-500 max-w-md mx-auto">
|
||||
Upload STL files when logging prints to build your model
|
||||
library. Go to <a
|
||||
href="/prints"
|
||||
class="text-blue-400 hover:underline">Prints</a
|
||||
> to add your first model.
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
>
|
||||
{#each models as model}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group cursor-pointer"
|
||||
onclick={() => openViewer(model)}
|
||||
>
|
||||
<Card
|
||||
class="overflow-hidden hover:ring-2 hover:ring-blue-500/50 transition-all duration-200"
|
||||
>
|
||||
<!-- Thumbnail/Preview -->
|
||||
<div
|
||||
class="aspect-square bg-slate-800 flex items-center justify-center relative overflow-hidden"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:cube-scan"
|
||||
class="w-20 h-20 text-slate-600 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-slate-900/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4"
|
||||
>
|
||||
<span
|
||||
class="text-white text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
<Icon icon="mdi:eye" class="w-4 h-4" />
|
||||
View Model
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-medium text-white truncate">
|
||||
{model.name}
|
||||
</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
{formatDate(model.date)}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-2 mt-2 text-xs text-slate-400"
|
||||
>
|
||||
{#if model.status === "Success"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-green-400"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:check-circle"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
Printed
|
||||
</span>
|
||||
{:else if model.status === "In Progress"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-blue-400"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:printer-3d"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
Printing
|
||||
</span>
|
||||
{:else if model.status === "Fail"}
|
||||
<span
|
||||
class="flex items-center gap-1 text-red-400"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:close-circle"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
Failed
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Full Screen Viewer Modal -->
|
||||
{#if showViewer && selectedModel && browser}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/90 z-50 flex flex-col"
|
||||
onclick={closeViewer}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-slate-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{selectedModel.name}
|
||||
</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
{formatDate(selectedModel.date)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
onclick={closeViewer}
|
||||
>
|
||||
<Icon icon="mdi:close" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Viewer -->
|
||||
<div
|
||||
class="flex-1 flex items-center justify-center p-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#await import("$lib/components/STLViewer.svelte") then { default: STLViewer }}
|
||||
<STLViewer
|
||||
modelPath={selectedModel.stl_file}
|
||||
width={Math.min(window.innerWidth - 48, 900)}
|
||||
height={Math.min(window.innerHeight - 200, 600)}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div
|
||||
class="p-4 border-t border-slate-800 flex items-center justify-center gap-8 text-sm"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#if selectedModel.filament_used_g}
|
||||
<div class="text-slate-400">
|
||||
<span class="text-slate-500">Filament:</span>
|
||||
{selectedModel.filament_used_g}g
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedModel.duration_minutes}
|
||||
<div class="text-slate-400">
|
||||
<span class="text-slate-500">Duration:</span>
|
||||
{Math.floor(selectedModel.duration_minutes / 60)}h {selectedModel.duration_minutes %
|
||||
60}m
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedModel.calculated_cost_filament}
|
||||
<div class="text-slate-400">
|
||||
<span class="text-slate-500">Cost:</span>
|
||||
${selectedModel.calculated_cost_filament.toFixed(2)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@ export const actions: Actions = {
|
||||
const status = formData.get('status');
|
||||
const manual_cost = formData.get('manual_cost');
|
||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||
const stl_file = formData.get('stl_file');
|
||||
|
||||
if (!spool_id || !printer_id || !filament_used_g) {
|
||||
return fail(400, { missing: true });
|
||||
@@ -110,6 +111,7 @@ export const actions: Actions = {
|
||||
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
||||
status,
|
||||
started_at: startedAt,
|
||||
stl_file: stl_file || null,
|
||||
date: new Date()
|
||||
});
|
||||
|
||||
@@ -139,6 +141,7 @@ export const actions: Actions = {
|
||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||
const printer_id = formData.get('printer_id');
|
||||
const spool_id = formData.get('spool_id');
|
||||
const stl_file = formData.get('stl_file');
|
||||
|
||||
if (!id || !name) {
|
||||
return fail(400, { missing: true });
|
||||
@@ -215,6 +218,10 @@ export const actions: Actions = {
|
||||
if (spool_id) {
|
||||
updateData.spool_id = spool_id;
|
||||
}
|
||||
// Update STL file if provided
|
||||
if (stl_file) {
|
||||
updateData.stl_file = stl_file;
|
||||
}
|
||||
|
||||
await PrintJob.findOneAndUpdate(
|
||||
{ _id: id, user_id: locals.user.id },
|
||||
|
||||
@@ -75,9 +75,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-white font-medium truncate">
|
||||
{print.name}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-white font-medium truncate">
|
||||
{print.name}
|
||||
</h3>
|
||||
{#if print.stl_file}
|
||||
<span title="Has 3D Model">
|
||||
<Icon
|
||||
icon="mdi:cube-scan"
|
||||
class="w-4 h-4 text-blue-400 shrink-0"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user