STL Model Viewer
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
static/uploads/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{ name: "Spools", href: "/spools", icon: "mdi:cylinder" },
|
{ name: "Spools", href: "/spools", icon: "mdi:cylinder" },
|
||||||
{ name: "Printers", href: "/printers", icon: "mdi:printer-3d" },
|
{ name: "Printers", href: "/printers", icon: "mdi:printer-3d" },
|
||||||
{ name: "Prints", href: "/prints", icon: "mdi:cube-outline" },
|
{ name: "Prints", href: "/prints", icon: "mdi:cube-outline" },
|
||||||
|
{ name: "Library", href: "/library", icon: "mdi:cube-scan" },
|
||||||
{ name: "Analytics", href: "/analytics", icon: "mdi:chart-line" },
|
{ name: "Analytics", href: "/analytics", icon: "mdi:chart-line" },
|
||||||
...($page.data.user?.role === "Admin"
|
...($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">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
import Button from "$lib/components/ui/Button.svelte";
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
import Modal from "$lib/components/ui/Modal.svelte";
|
import Modal from "$lib/components/ui/Modal.svelte";
|
||||||
import Input from "$lib/components/ui/Input.svelte";
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
@@ -45,6 +46,63 @@
|
|||||||
handleClose();
|
handleClose();
|
||||||
window.location.reload();
|
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>
|
</script>
|
||||||
|
|
||||||
<Modal title="Edit Print Log" {open} onclose={handleClose}>
|
<Modal title="Edit Print Log" {open} onclose={handleClose}>
|
||||||
@@ -52,7 +110,17 @@
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/edit"
|
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
|
// Convert hours + minutes to total minutes
|
||||||
const hours = Number(formData.get("duration_hours") || 0);
|
const hours = Number(formData.get("duration_hours") || 0);
|
||||||
const mins = Number(formData.get("duration_mins") || 0);
|
const mins = Number(formData.get("duration_mins") || 0);
|
||||||
@@ -66,7 +134,6 @@
|
|||||||
String(elapsedHours * 60 + elapsedMins),
|
String(elapsedHours * 60 + elapsedMins),
|
||||||
);
|
);
|
||||||
|
|
||||||
isSubmitting = true;
|
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
await update();
|
await update();
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
@@ -146,6 +213,95 @@
|
|||||||
|
|
||||||
<Input label="Print Name" name="name" value={print.name} required />
|
<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"}
|
{#if editStatus === "In Progress"}
|
||||||
<!-- In Progress specific fields -->
|
<!-- In Progress specific fields -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -1,260 +1,473 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from "$app/forms";
|
import { enhance } from "$app/forms";
|
||||||
import Button from "$lib/components/ui/Button.svelte";
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
import Modal from "$lib/components/ui/Modal.svelte";
|
import Modal from "$lib/components/ui/Modal.svelte";
|
||||||
import Input from "$lib/components/ui/Input.svelte";
|
import Input from "$lib/components/ui/Input.svelte";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
printers: any[];
|
printers: any[];
|
||||||
spools: any[];
|
spools: any[];
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, printers, spools, onclose }: Props = $props();
|
let { open, printers, spools, onclose }: Props = $props();
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
let selectedStatus = $state("Success");
|
let selectedStatus = $state("Success");
|
||||||
|
let stlFile = $state<File | null>(null);
|
||||||
|
let stlPath = $state("");
|
||||||
|
let uploadProgress = $state(0);
|
||||||
|
let uploadStatus = $state("");
|
||||||
|
|
||||||
function handleClose() {
|
function uploadSTL(): Promise<string> {
|
||||||
selectedStatus = "Success";
|
return new Promise((resolve) => {
|
||||||
onclose();
|
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>
|
</script>
|
||||||
|
|
||||||
<Modal title="Log a Print" {open} onclose={handleClose}>
|
<Modal title="Log a Print" {open} onclose={handleClose}>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/log"
|
action="?/log"
|
||||||
use:enhance={({ formData }) => {
|
use:enhance={async ({ formData }) => {
|
||||||
// Convert hours + minutes to total minutes
|
isSubmitting = true;
|
||||||
const hours = Number(formData.get('duration_hours') || 0);
|
|
||||||
const mins = Number(formData.get('duration_mins') || 0);
|
|
||||||
formData.set('duration_minutes', String(hours * 60 + mins));
|
|
||||||
|
|
||||||
// Convert elapsed hours + minutes to total minutes
|
// Upload STL file first if selected
|
||||||
const elapsedHours = Number(formData.get('elapsed_hours') || 0);
|
if (stlFile) {
|
||||||
const elapsedMins = Number(formData.get('elapsed_mins') || 0);
|
const uploadedPath = await uploadSTL();
|
||||||
formData.set('elapsed_minutes', String(elapsedHours * 60 + elapsedMins));
|
if (uploadedPath) {
|
||||||
|
formData.set("stl_file", uploadedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isSubmitting = true;
|
// Convert hours + minutes to total minutes
|
||||||
return async ({ update }) => {
|
const hours = Number(formData.get("duration_hours") || 0);
|
||||||
await update();
|
const mins = Number(formData.get("duration_mins") || 0);
|
||||||
isSubmitting = false;
|
formData.set("duration_minutes", String(hours * 60 + mins));
|
||||||
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
|
// Convert elapsed hours + minutes to total minutes
|
||||||
label="Print Name"
|
const elapsedHours = Number(formData.get("elapsed_hours") || 0);
|
||||||
name="name"
|
const elapsedMins = Number(formData.get("elapsed_mins") || 0);
|
||||||
placeholder="Dragon Scale Mail"
|
formData.set(
|
||||||
required
|
"elapsed_minutes",
|
||||||
/>
|
String(elapsedHours * 60 + elapsedMins),
|
||||||
|
);
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
return async ({ update }) => {
|
||||||
<div class="space-y-2">
|
await update();
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
isSubmitting = false;
|
||||||
<label
|
handleClose();
|
||||||
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
};
|
||||||
>Printer</label
|
}}
|
||||||
>
|
class="space-y-4"
|
||||||
<select
|
>
|
||||||
name="printer_id"
|
<!-- Status Selection First -->
|
||||||
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"
|
<div class="space-y-2">
|
||||||
>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
{#each printers as p}
|
<label
|
||||||
<option value={p._id}>{p.name}</option>
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
{/each}
|
>Status</label
|
||||||
</select>
|
>
|
||||||
</div>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="space-y-2">
|
<label class="cursor-pointer">
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
<input
|
||||||
<label
|
type="radio"
|
||||||
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
name="status"
|
||||||
>Spool</label
|
value="In Progress"
|
||||||
>
|
class="peer sr-only"
|
||||||
<select
|
bind:group={selectedStatus}
|
||||||
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"
|
<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"
|
||||||
{#each spools as s}
|
>
|
||||||
<option value={s._id}
|
<Icon icon="mdi:printer-3d" class="w-4 h-4" /> In Progress
|
||||||
>{s.brand} {s.material} ({s.weight_remaining_g}g left)</option
|
</div>
|
||||||
>
|
</label>
|
||||||
{/each}
|
<label class="cursor-pointer">
|
||||||
</select>
|
<input
|
||||||
</div>
|
type="radio"
|
||||||
</div>
|
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"}
|
<Input
|
||||||
<!-- In Progress specific fields -->
|
label="Print Name"
|
||||||
<div class="p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
name="name"
|
||||||
<p class="text-xs text-blue-300 mb-3">
|
placeholder="Dragon Scale Mail"
|
||||||
<Icon icon="mdi:information" class="w-4 h-4 inline mr-1" />
|
required
|
||||||
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">
|
<!-- 3D Model Upload -->
|
||||||
<Button variant="ghost" onclick={handleClose} type="button">Cancel</Button
|
<div class="space-y-2">
|
||||||
>
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<label
|
||||||
{isSubmitting
|
class="block text-xs font-medium text-slate-400 uppercase tracking-wider"
|
||||||
? "Saving..."
|
>
|
||||||
: selectedStatus === "In Progress"
|
3D Model (Optional)
|
||||||
? "Start Print"
|
</label>
|
||||||
: "Save Log"}
|
|
||||||
</Button>
|
{#if uploadStatus === "Uploading..." && uploadProgress > 0}
|
||||||
</div>
|
<!-- Progress Bar -->
|
||||||
</form>
|
<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>
|
</Modal>
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ const printJobSchema = new mongoose.Schema({
|
|||||||
type: Date
|
type: Date
|
||||||
},
|
},
|
||||||
notes: String,
|
notes: String,
|
||||||
|
stl_file: {
|
||||||
|
type: String // Path to uploaded STL file
|
||||||
|
},
|
||||||
date: {
|
date: {
|
||||||
type: Date,
|
type: Date,
|
||||||
default: Date.now
|
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 status = formData.get('status');
|
||||||
const manual_cost = formData.get('manual_cost');
|
const manual_cost = formData.get('manual_cost');
|
||||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||||
|
const stl_file = formData.get('stl_file');
|
||||||
|
|
||||||
if (!spool_id || !printer_id || !filament_used_g) {
|
if (!spool_id || !printer_id || !filament_used_g) {
|
||||||
return fail(400, { missing: true });
|
return fail(400, { missing: true });
|
||||||
@@ -110,6 +111,7 @@ export const actions: Actions = {
|
|||||||
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
calculated_cost_energy: Number(costEnergy.toFixed(2)),
|
||||||
status,
|
status,
|
||||||
started_at: startedAt,
|
started_at: startedAt,
|
||||||
|
stl_file: stl_file || null,
|
||||||
date: new Date()
|
date: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +141,7 @@ export const actions: Actions = {
|
|||||||
const elapsed_minutes = formData.get('elapsed_minutes');
|
const elapsed_minutes = formData.get('elapsed_minutes');
|
||||||
const printer_id = formData.get('printer_id');
|
const printer_id = formData.get('printer_id');
|
||||||
const spool_id = formData.get('spool_id');
|
const spool_id = formData.get('spool_id');
|
||||||
|
const stl_file = formData.get('stl_file');
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
return fail(400, { missing: true });
|
return fail(400, { missing: true });
|
||||||
@@ -215,6 +218,10 @@ export const actions: Actions = {
|
|||||||
if (spool_id) {
|
if (spool_id) {
|
||||||
updateData.spool_id = spool_id;
|
updateData.spool_id = spool_id;
|
||||||
}
|
}
|
||||||
|
// Update STL file if provided
|
||||||
|
if (stl_file) {
|
||||||
|
updateData.stl_file = stl_file;
|
||||||
|
}
|
||||||
|
|
||||||
await PrintJob.findOneAndUpdate(
|
await PrintJob.findOneAndUpdate(
|
||||||
{ _id: id, user_id: locals.user.id },
|
{ _id: id, user_id: locals.user.id },
|
||||||
|
|||||||
@@ -75,9 +75,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-white font-medium truncate">
|
<div class="flex items-center gap-2">
|
||||||
{print.name}
|
<h3 class="text-white font-medium truncate">
|
||||||
</h3>
|
{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
|
<div
|
||||||
class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400"
|
class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-slate-400"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user