completely refactored utilities for direct rendering, killed iframes
This commit is contained in:
parent
12dd2c6c06
commit
f08a7456ee
37 changed files with 1824 additions and 1350 deletions
|
|
@ -1,539 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// ---------- Preview + batch helpers ----------
|
||||
const PREVIEW_MAX = 2048; // max long-edge for on-screen previews
|
||||
const BATCH_SIZES = [2048, 1536, 1280, 1024, 864, 720]; // adaptive preview long-edges
|
||||
const COOLDOWN_MS = 150; // tiny cooldown between requests to ease VRAM
|
||||
|
||||
async function makePreview(blob: Blob, maxEdge = PREVIEW_MAX): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const { width, height } = bitmap;
|
||||
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
||||
const outW = Math.max(1, Math.round(width * scale));
|
||||
const outH = Math.max(1, Math.round(height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0, outW, outH);
|
||||
bitmap.close();
|
||||
const outBlob = await new Promise<Blob>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), "image/png")
|
||||
);
|
||||
return outBlob;
|
||||
}
|
||||
|
||||
function revoke(url: string | null | undefined) {
|
||||
if (!url) return;
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------- Methods ----------
|
||||
type Canonical =
|
||||
| "ormbg"
|
||||
| "u2net"
|
||||
| "basnet"
|
||||
| "deeplab"
|
||||
| "tracer"
|
||||
| "u2net_human_seg"
|
||||
| "isnet-general-use"
|
||||
| "isnet-anime"
|
||||
| "bria"
|
||||
| "inspyrenet";
|
||||
|
||||
const METHODS: { key: Canonical; label: string }[] = [
|
||||
{ key: "ormbg", label: "ORMBG" },
|
||||
{ key: "u2net", label: "U2NET" },
|
||||
{ key: "basnet", label: "BASNET" },
|
||||
{ key: "deeplab", label: "DEEPLAB" },
|
||||
{ key: "tracer", label: "TRACER-B7" },
|
||||
{ key: "u2net_human_seg", label: "U2NET (Human)" },
|
||||
{ key: "isnet-general-use", label: "ISNET (General)" },
|
||||
{ key: "isnet-anime", label: "ISNET (Anime)" },
|
||||
{ key: "bria", label: "BRIA RMBG1.4" },
|
||||
{ key: "inspyrenet", label: "INSPYRENET" },
|
||||
];
|
||||
|
||||
const DEFAULT_CONCURRENCY = 2;
|
||||
|
||||
type Status = "idle" | "pending" | "ok" | "error";
|
||||
|
||||
type ResultMap = {
|
||||
[K in Canonical]?: {
|
||||
fullBlob: Blob;
|
||||
previewUrl: string;
|
||||
bytes: number;
|
||||
ms: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function BackgroundRemoverPage() {
|
||||
// ---------- State ----------
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<Record<Canonical, Status>>(
|
||||
() => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record<Canonical, Status>
|
||||
);
|
||||
const [results, setResults] = useState<ResultMap>({});
|
||||
const resultsRef = useRef<ResultMap>({});
|
||||
useEffect(() => {
|
||||
resultsRef.current = results;
|
||||
}, [results]);
|
||||
|
||||
const [active, setActive] = useState<Canonical | null>(null);
|
||||
const [reveal, setReveal] = useState<number>(50);
|
||||
const [gpuSafe, setGpuSafe] = useState(true);
|
||||
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const batchBlobCache = useRef<Map<number, Blob>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revoke(sourceUrl);
|
||||
Object.values(resultsRef.current).forEach((v) => revoke(v?.previewUrl));
|
||||
};
|
||||
}, [sourceUrl]);
|
||||
|
||||
// ---------- Styles ----------
|
||||
const styles = (
|
||||
<style>{`
|
||||
html, body { width: 100%; overflow-x: hidden; }
|
||||
:root { font-size: 17px; }
|
||||
.checkerboard {
|
||||
background-size: 24px 24px;
|
||||
background-image:
|
||||
linear-gradient(45deg,#2a2a2a 25%,transparent 25%),
|
||||
linear-gradient(-45deg,#2a2a2a 25%,transparent 25%),
|
||||
linear-gradient(45deg,transparent 75%,#2a2a2a 75%),
|
||||
linear-gradient(-45deg,transparent 75%,#2a2a2a 75%);
|
||||
background-position: 0 0,0 12px,12px -12px,-12px 0;
|
||||
}
|
||||
.slider-handle { position: absolute; top: 0; bottom: 0; width: 0; left: calc(var(--reveal, 50) * 1%); }
|
||||
.slider-handle::before { content: ""; position: absolute; top: 0; bottom: 0; width: 2px; left: -1px; background: rgba(255,255,255,0.85); }
|
||||
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 26px; height: 26px; border-radius: 9999px; background: rgba(24,24,27,0.9); border: 1px solid rgba(255,255,255,0.85); display: grid; place-items: center; cursor: ew-resize; }
|
||||
/* Mobile: keep the page from panning left/right while using the slider */
|
||||
.app-frame { touch-action: pan-y; overscroll-behavior-x: contain; }
|
||||
`}</style>
|
||||
);
|
||||
|
||||
// ---------- File pick ----------
|
||||
const onPick = useCallback(
|
||||
async (f: File | null) => {
|
||||
revoke(sourceUrl);
|
||||
Object.values(resultsRef.current).forEach((v) => revoke(v?.previewUrl));
|
||||
batchBlobCache.current.clear();
|
||||
|
||||
setFile(f);
|
||||
setResults({});
|
||||
setActive(null);
|
||||
setReveal(50);
|
||||
setStatus(Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as any);
|
||||
|
||||
if (!f) {
|
||||
setSourceUrl(null);
|
||||
setNatural(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bmp = await createImageBitmap(f);
|
||||
setNatural({ w: bmp.width, h: bmp.height });
|
||||
bmp.close();
|
||||
} catch {}
|
||||
|
||||
const previewBlob = await makePreview(f, PREVIEW_MAX);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
setSourceUrl(previewUrl);
|
||||
},
|
||||
[sourceUrl]
|
||||
);
|
||||
|
||||
// Get or create a cached resized blob for batch preview runs
|
||||
const getBatchBlob = useCallback(
|
||||
async (longEdge: number): Promise<Blob> => {
|
||||
const cache = batchBlobCache.current;
|
||||
if (cache.has(longEdge)) return cache.get(longEdge)!;
|
||||
if (!file) throw new Error("No file selected");
|
||||
const b = await makePreview(file, longEdge);
|
||||
cache.set(longEdge, b);
|
||||
return b;
|
||||
},
|
||||
[file]
|
||||
);
|
||||
|
||||
// ---------- Batch run (adaptive) ----------
|
||||
const startAll = useCallback(async () => {
|
||||
if (!file) return;
|
||||
|
||||
setResults({});
|
||||
setStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
METHODS.forEach((m) => (next[m.key] = "pending"));
|
||||
return next;
|
||||
});
|
||||
|
||||
const runOne = async (key: Canonical) => {
|
||||
// When GPU-safe is on, try progressively smaller long-edge previews.
|
||||
const sizes = gpuSafe ? BATCH_SIZES : [Math.max(natural?.w || 0, natural?.h || 0) || 4096];
|
||||
|
||||
let lastErr: string | null = null;
|
||||
const t0 = performance.now();
|
||||
|
||||
for (const size of sizes) {
|
||||
try {
|
||||
const blobToSend = gpuSafe ? await getBatchBlob(size) : file!;
|
||||
const fd = new FormData();
|
||||
fd.append("file", blobToSend);
|
||||
fd.append("method", key);
|
||||
|
||||
const res = await fetch("/api/bgremove", { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
const retryable = /out of memory|onnxruntime|cuda|allocate|500/i.test(txt);
|
||||
if (gpuSafe && retryable) {
|
||||
lastErr = txt || `HTTP ${res.status}`;
|
||||
continue; // try next smaller size
|
||||
}
|
||||
throw new Error(txt || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const outBlob = await res.blob();
|
||||
const ms = performance.now() - t0;
|
||||
const previewBlob = await makePreview(outBlob);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
|
||||
setResults((r) => ({
|
||||
...r,
|
||||
[key]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms },
|
||||
}));
|
||||
setStatus((s) => ({ ...s, [key]: "ok" }));
|
||||
await new Promise((r) => setTimeout(r, COOLDOWN_MS)); // tiny cooldown
|
||||
return;
|
||||
} catch (e: any) {
|
||||
lastErr = e?.message || String(e);
|
||||
if (!gpuSafe) break; // not in adaptive mode, bail after first failure
|
||||
}
|
||||
}
|
||||
|
||||
setStatus((s) => ({ ...s, [key]: "error" }));
|
||||
// (Optional) console.debug("Last error for", key, lastErr);
|
||||
};
|
||||
|
||||
const concurrency = gpuSafe ? 1 : DEFAULT_CONCURRENCY;
|
||||
const queue = [...METHODS.map((m) => m.key)];
|
||||
let inFlight: Promise<void>[] = [];
|
||||
|
||||
const launch = () => {
|
||||
while (inFlight.length < concurrency && queue.length) {
|
||||
const key = queue.shift()!;
|
||||
const p = runOne(key).finally(() => {
|
||||
inFlight = inFlight.filter((q) => q !== p);
|
||||
});
|
||||
inFlight.push(p);
|
||||
}
|
||||
};
|
||||
|
||||
launch();
|
||||
while (inFlight.length) {
|
||||
await Promise.race(inFlight);
|
||||
launch();
|
||||
}
|
||||
|
||||
setActive((prev) => {
|
||||
if (prev) return prev;
|
||||
for (const m of METHODS) if ((resultsRef.current as any)[m.key]) return m.key;
|
||||
return METHODS[0]?.key ?? null;
|
||||
});
|
||||
}, [file, gpuSafe, natural, getBatchBlob]);
|
||||
|
||||
// ---------- Upload & slider handlers ----------
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
};
|
||||
const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
onPick(f);
|
||||
};
|
||||
|
||||
const aspect = useMemo(() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9), [natural]);
|
||||
|
||||
const updateByClientX = useCallback((clientX: number) => {
|
||||
const el = frameRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = ((clientX - rect.left) / rect.width) * 100;
|
||||
setReveal(Math.min(100, Math.max(0, pct)));
|
||||
}, []);
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
draggingRef.current = true;
|
||||
updateByClientX(e.clientX);
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
updateByClientX(e.clientX);
|
||||
};
|
||||
const onMouseUp = () => (draggingRef.current = false);
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
draggingRef.current = true;
|
||||
updateByClientX(e.touches[0].clientX);
|
||||
};
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
e.preventDefault(); // keep page from horizontal panning while sliding
|
||||
updateByClientX(e.touches[0].clientX);
|
||||
};
|
||||
const onTouchEnd = () => (draggingRef.current = false);
|
||||
|
||||
// ---------- Active result & actions ----------
|
||||
const activeResult = active ? results[active] : undefined;
|
||||
const canDownload = Boolean(active && activeResult?.fullBlob);
|
||||
|
||||
const download = () => {
|
||||
if (!active || !activeResult) return;
|
||||
const a = document.createElement("a");
|
||||
const base = file?.name?.replace(/\.[^.]+$/, "") || "image";
|
||||
const fullUrl = URL.createObjectURL(activeResult.fullBlob);
|
||||
a.href = fullUrl;
|
||||
setTimeout(() => revoke(fullUrl), 5000);
|
||||
a.download = `${base}_${active}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
// Re-run the selected method on the ORIGINAL file for full-resolution output
|
||||
const renderFullRes = useCallback(async () => {
|
||||
if (!file || !active) return;
|
||||
setStatus((s) => ({ ...s, [active]: "pending" }));
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("method", active);
|
||||
const res = await fetch("/api/bgremove", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const outBlob = await res.blob();
|
||||
const ms = performance.now() - t0;
|
||||
const previewBlob = await makePreview(outBlob);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
const prev = resultsRef.current[active];
|
||||
if (prev) revoke(prev.previewUrl);
|
||||
setResults((r) => ({ ...r, [active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms } }));
|
||||
setStatus((s) => ({ ...s, [active]: "ok" }));
|
||||
} catch {
|
||||
setStatus((s) => ({ ...s, [active]: "error" }));
|
||||
}
|
||||
}, [file, active]);
|
||||
|
||||
const doneCount = useMemo(() => METHODS.filter((m) => status[m.key] === "ok").length, [status]);
|
||||
const pendingCount = useMemo(() => METHODS.filter((m) => status[m.key] === "pending").length, [status]);
|
||||
|
||||
function StatusDot({ s }: { s: Status }) {
|
||||
const cls =
|
||||
s === "ok"
|
||||
? "bg-emerald-500"
|
||||
: s === "pending"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: s === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-600";
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${cls}`} />;
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
return (
|
||||
<div className="p-6 text-zinc-100 overflow-x-hidden">
|
||||
{styles}
|
||||
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4">
|
||||
{/* Header row: title left, back button right */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Background Remover</h1>
|
||||
<a
|
||||
href="https://makearmy.io"
|
||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
|
||||
>
|
||||
Back to main
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Source filename */}
|
||||
<div className="text-zinc-400 mb-3">
|
||||
<span className="text-zinc-300">Source:</span>{" "}
|
||||
{file?.name ?? <span className="italic">— none —</span>}
|
||||
</div>
|
||||
|
||||
{/* Preview frame */}
|
||||
<div
|
||||
ref={frameRef}
|
||||
className="app-frame checkerboard relative w-full rounded-2xl border border-zinc-800/80 shadow-inner"
|
||||
style={{ aspectRatio: `${aspect}`, maxWidth: "1200px", maxHeight: "80vh", marginInline: "auto" }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseUp}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Drop hint */}
|
||||
{!sourceUrl && (
|
||||
<label className="absolute inset-0 grid place-items-center cursor-pointer">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={onSelect} />
|
||||
<div className="text-zinc-400 border-2 border-dashed border-zinc-600/70 rounded-xl px-6 py-10">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">Drop an image here</div>
|
||||
<div className="text-zinc-500">or click to select a file</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Before/After */}
|
||||
{sourceUrl && (
|
||||
<>
|
||||
{/* LEFT (BEFORE) */}
|
||||
<img
|
||||
src={sourceUrl}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-contain select-none"
|
||||
alt="Source"
|
||||
style={{ clipPath: `inset(0 0 0 ${reveal}%)` }}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* RIGHT (AFTER) */}
|
||||
{activeResult ? (
|
||||
<img
|
||||
src={activeResult.previewUrl}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-contain select-none pointer-events-none"
|
||||
alt="Result"
|
||||
style={{ clipPath: `inset(0 ${100 - reveal}% 0 0)` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : status[active as Canonical] === "pending" ? (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Divider & Thumb */}
|
||||
<div
|
||||
className="slider-handle"
|
||||
style={{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties}
|
||||
>
|
||||
<div className="slider-thumb">
|
||||
<div className="w-1.5 h-4 bg-white/80 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Method options – EVEN GRID under the preview */}
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{METHODS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${
|
||||
active === key ? "border-blue-400 bg-blue-500/20" : "border-zinc-700 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
onClick={() => setActive(key)}
|
||||
disabled={!file}
|
||||
title={!file ? "Select a file first" : label}
|
||||
>
|
||||
<StatusDot s={status[key]} />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions & global status */}
|
||||
<div className="mt-4 flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={startAll}
|
||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 flex items-center gap-2 order-0"
|
||||
disabled={!file || pendingCount > 0}
|
||||
title={!file ? "Select a file first" : pendingCount > 0 ? "Running…" : "Run all methods"}
|
||||
>
|
||||
{pendingCount > 0 && <Loader2 className="animate-spin w-4 h-4" />}{" "}
|
||||
{pendingCount > 0 ? `Running… ${doneCount}/${METHODS.length}` : "Run all methods"}
|
||||
</button>
|
||||
|
||||
{/* GPU-safe toggle: sits next to Run All on mobile; keeps position naturally on larger screens */}
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1">
|
||||
<input type="checkbox" checked={gpuSafe} onChange={(e) => setGpuSafe(e.target.checked)} /> GPU-safe mode
|
||||
</label>
|
||||
|
||||
<div className="text-zinc-400 text-sm order-2">
|
||||
{file ? (
|
||||
pendingCount > 0 ? (
|
||||
<span>Processing… {doneCount}/{METHODS.length} finished</span>
|
||||
) : doneCount > 0 ? (
|
||||
<span>Done: {doneCount} methods succeeded</span>
|
||||
) : (
|
||||
<span>Ready. Click Run all methods</span>
|
||||
)
|
||||
) : (
|
||||
<span>Drop an image to begin</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-side controls collapse under on mobile */}
|
||||
<div className="sm:ml-auto flex items-center gap-3 w-full sm:w-auto order-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={reveal}
|
||||
onChange={(e) => setReveal(parseInt(e.target.value, 10))}
|
||||
className="w-full sm:w-56"
|
||||
title="Slide to compare before/after"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={renderFullRes}
|
||||
disabled={!file || !active}
|
||||
className={`px-3 py-1 rounded-md border ${
|
||||
file && active
|
||||
? "border-sky-600 bg-sky-600/20 hover:bg-sky-600/30"
|
||||
: "border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||
}`}
|
||||
title={!file ? "Select a file first" : !active ? "Choose a method" : "Render selected method at full resolution"}
|
||||
>
|
||||
Full-res render
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={download}
|
||||
disabled={!canDownload}
|
||||
className={`px-3 py-1 rounded-md border ${
|
||||
canDownload
|
||||
? "border-emerald-600 bg-emerald-600/20 hover:bg-emerald-600/30"
|
||||
: "border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// ---- Preview helpers & URL lifecycle ----
|
||||
const PREVIEW_MAX = 2048; // max long-edge for on-screen previews
|
||||
|
||||
async function makePreview(blob: Blob, maxEdge = PREVIEW_MAX): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const { width, height } = bitmap;
|
||||
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
||||
const outW = Math.max(1, Math.round(width * scale));
|
||||
const outH = Math.max(1, Math.round(height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0, outW, outH);
|
||||
bitmap.close();
|
||||
return await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), "image/png"));
|
||||
}
|
||||
|
||||
function revoke(url: string | null | undefined) {
|
||||
if (!url) return;
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
type Canonical =
|
||||
| "ormbg"
|
||||
| "u2net"
|
||||
| "basnet"
|
||||
| "deeplab"
|
||||
| "tracer"
|
||||
| "u2net_human_seg"
|
||||
| "isnet-general-use"
|
||||
| "isnet-anime"
|
||||
| "bria"
|
||||
| "inspyrenet";
|
||||
|
||||
const METHODS: { key: Canonical; label: string }[] = [
|
||||
{ key: "ormbg", label: "ORMBG" },
|
||||
{ key: "u2net", label: "U2NET" },
|
||||
{ key: "basnet", label: "BASNET" },
|
||||
{ key: "deeplab", label: "DEEPLAB" },
|
||||
{ key: "tracer", label: "TRACER-B7" },
|
||||
{ key: "u2net_human_seg", label: "U2NET (Human)" },
|
||||
{ key: "isnet-general-use", label: "ISNET (General)" },
|
||||
{ key: "isnet-anime", label: "ISNET (Anime)" },
|
||||
{ key: "bria", label: "BRIA RMBG1.4" },
|
||||
{ key: "inspyrenet", label: "INSPYRENET" },
|
||||
];
|
||||
|
||||
const DEFAULT_CONCURRENCY = 2;
|
||||
|
||||
type Status = "idle" | "pending" | "ok" | "error";
|
||||
|
||||
type ResultMap = {
|
||||
[K in Canonical]?: {
|
||||
fullBlob: Blob;
|
||||
previewUrl: string;
|
||||
bytes: number;
|
||||
ms: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function BackgroundRemoverPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<Record<Canonical, Status>>(
|
||||
() => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record<Canonical, Status>
|
||||
);
|
||||
const [results, setResults] = useState<ResultMap>({});
|
||||
const resultsRef = useRef<ResultMap>({});
|
||||
useEffect(() => {
|
||||
resultsRef.current = results;
|
||||
}, [results]);
|
||||
|
||||
const [active, setActive] = useState<Canonical | null>(null);
|
||||
const [reveal, setReveal] = useState<number>(50);
|
||||
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [gpuSafe, setGpuSafe] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
revoke(sourceUrl);
|
||||
Object.values(resultsRef.current).forEach((v) => revoke(v?.previewUrl));
|
||||
};
|
||||
}, [sourceUrl]);
|
||||
|
||||
const styles = (
|
||||
<style>{`
|
||||
html, body { width: 100%; overflow-x: hidden; }
|
||||
:root { font-size: 17px; }
|
||||
.checkerboard {
|
||||
background-size: 24px 24px;
|
||||
background-image:
|
||||
linear-gradient(45deg,#2a2a2a 25%,transparent 25%),
|
||||
linear-gradient(-45deg,#2a2a2a 25%,transparent 25%),
|
||||
linear-gradient(45deg,transparent 75%,#2a2a2a 75%),
|
||||
linear-gradient(-45deg,transparent 75%,#2a2a2a 75%);
|
||||
background-position: 0 0,0 12px,12px -12px,-12px 0;
|
||||
}
|
||||
.slider-handle { position: absolute; top: 0; bottom: 0; width: 0; left: calc(var(--reveal, 50) * 1%); }
|
||||
.slider-handle::before { content: ""; position: absolute; top: 0; bottom: 0; width: 2px; left: -1px; background: rgba(255,255,255,0.85); }
|
||||
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 26px; height: 26px; border-radius: 9999px; background: rgba(24,24,27,0.9); border: 1px solid rgba(255,255,255,0.85); display: grid; place-items: center; cursor: ew-resize; }
|
||||
/* Mobile: keep the page from panning left/right while using the slider */
|
||||
.app-frame { touch-action: pan-y; overscroll-behavior-x: contain; }
|
||||
`}</style>
|
||||
);
|
||||
|
||||
const onPick = useCallback(
|
||||
async (f: File | null) => {
|
||||
revoke(sourceUrl);
|
||||
Object.values(resultsRef.current).forEach((v) => revoke(v?.previewUrl));
|
||||
|
||||
setFile(f);
|
||||
setResults({});
|
||||
setActive(null);
|
||||
setReveal(50);
|
||||
|
||||
if (!f) {
|
||||
setSourceUrl(null);
|
||||
setNatural(null);
|
||||
setStatus(Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bmp = await createImageBitmap(f);
|
||||
setNatural({ w: bmp.width, h: bmp.height });
|
||||
bmp.close();
|
||||
} catch {}
|
||||
|
||||
const previewBlob = await makePreview(f, PREVIEW_MAX);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
setSourceUrl(previewUrl);
|
||||
},
|
||||
[sourceUrl]
|
||||
);
|
||||
|
||||
const startAll = useCallback(async () => {
|
||||
if (!file) return;
|
||||
|
||||
setResults({});
|
||||
setStatus((prev) => {
|
||||
const next = { ...prev };
|
||||
METHODS.forEach((m) => (next[m.key] = "pending"));
|
||||
return next;
|
||||
});
|
||||
|
||||
const runOne = async (key: Canonical) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("method", key);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const res = await fetch("/api/bgremove", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const ms = performance.now() - t0;
|
||||
const previewBlob = await makePreview(blob);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
setResults((r) => ({ ...r, [key]: { fullBlob: blob, previewUrl, bytes: blob.size, ms } }));
|
||||
setStatus((s) => ({ ...s, [key]: "ok" }));
|
||||
} catch {
|
||||
setStatus((s) => ({ ...s, [key]: "error" }));
|
||||
}
|
||||
};
|
||||
|
||||
const concurrency = gpuSafe ? 1 : DEFAULT_CONCURRENCY;
|
||||
const queue = [...METHODS.map((m) => m.key)];
|
||||
let inFlight: Promise<void>[] = [];
|
||||
|
||||
const launch = () => {
|
||||
while (inFlight.length < concurrency && queue.length) {
|
||||
const key = queue.shift()!;
|
||||
const p = runOne(key).finally(() => {
|
||||
inFlight = inFlight.filter((q) => q !== p);
|
||||
});
|
||||
inFlight.push(p);
|
||||
}
|
||||
};
|
||||
|
||||
launch();
|
||||
while (inFlight.length) {
|
||||
await Promise.race(inFlight);
|
||||
launch();
|
||||
}
|
||||
|
||||
setActive((prev) => {
|
||||
if (prev) return prev;
|
||||
for (const m of METHODS) if ((resultsRef.current as any)[m.key]) return m.key;
|
||||
return METHODS[0]?.key ?? null;
|
||||
});
|
||||
}, [file, gpuSafe]);
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) onPick(f);
|
||||
};
|
||||
const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0] ?? null;
|
||||
onPick(f);
|
||||
};
|
||||
|
||||
const aspect = useMemo(() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9), [natural]);
|
||||
|
||||
// Slider drag
|
||||
const updateByClientX = useCallback((clientX: number) => {
|
||||
const el = frameRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = ((clientX - rect.left) / rect.width) * 100;
|
||||
setReveal(Math.min(100, Math.max(0, pct)));
|
||||
}, []);
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
draggingRef.current = true;
|
||||
updateByClientX(e.clientX);
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
updateByClientX(e.clientX);
|
||||
};
|
||||
const onMouseUp = () => (draggingRef.current = false);
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
draggingRef.current = true;
|
||||
updateByClientX(e.touches[0].clientX);
|
||||
};
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
// Prevent the page from horizontal panning while using the slider
|
||||
e.preventDefault();
|
||||
updateByClientX(e.touches[0].clientX);
|
||||
};
|
||||
const onTouchEnd = () => (draggingRef.current = false);
|
||||
|
||||
const activeResult = active ? results[active] : undefined;
|
||||
const canDownload = Boolean(active && activeResult?.fullBlob);
|
||||
|
||||
const download = () => {
|
||||
if (!active || !activeResult) return;
|
||||
const a = document.createElement("a");
|
||||
const base = file?.name?.replace(/\.[^.]+$/, "") || "image";
|
||||
const fullUrl = URL.createObjectURL(activeResult.fullBlob);
|
||||
a.href = fullUrl;
|
||||
setTimeout(() => revoke(fullUrl), 5000);
|
||||
a.download = `${base}_${active}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
const doneCount = useMemo(() => METHODS.filter((m) => status[m.key] === "ok").length, [status]);
|
||||
const pendingCount = useMemo(() => METHODS.filter((m) => status[m.key] === "pending").length, [status]);
|
||||
|
||||
function StatusDot({ s }: { s: Status }) {
|
||||
const cls =
|
||||
s === "ok"
|
||||
? "bg-emerald-500"
|
||||
: s === "pending"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: s === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-600";
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${cls}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 text-zinc-100 overflow-x-hidden">
|
||||
{styles}
|
||||
|
||||
{/* Header row: title left, back button right */}
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Background Remover</h1>
|
||||
<a
|
||||
href="https://makearmy.io"
|
||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
|
||||
>
|
||||
Back to main
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Source filename */}
|
||||
<div className="text-zinc-400 mb-3">
|
||||
<span className="text-zinc-300">Source:</span>{" "}
|
||||
{file?.name ?? <span className="italic">— none —</span>}
|
||||
</div>
|
||||
|
||||
{/* Frame */}
|
||||
<div
|
||||
ref={frameRef}
|
||||
className="app-frame checkerboard relative w-full rounded-2xl border border-zinc-800/80 shadow-inner"
|
||||
style={{ aspectRatio: `${aspect}`, maxWidth: "1200px", maxHeight: "80vh", marginInline: "auto" }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={onDrop}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseUp}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Drop hint */}
|
||||
{!sourceUrl && (
|
||||
<label className="absolute inset-0 grid place-items-center cursor-pointer">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={onSelect} />
|
||||
<div className="text-zinc-400 border-2 border-dashed border-zinc-600/70 rounded-xl px-6 py-10">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">Drop an image here</div>
|
||||
<div className="text-zinc-500">or click to select a file</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Before/After */}
|
||||
{sourceUrl && (
|
||||
<>
|
||||
{/* LEFT (BEFORE) */}
|
||||
<img
|
||||
src={sourceUrl}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-contain select-none"
|
||||
alt="Source"
|
||||
style={{ clipPath: `inset(0 0 0 ${reveal}%)` }}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* RIGHT (AFTER) */}
|
||||
{activeResult ? (
|
||||
<img
|
||||
src={activeResult.previewUrl}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-contain select-none pointer-events-none"
|
||||
alt="Result"
|
||||
style={{ clipPath: `inset(0 ${100 - reveal}% 0 0)` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : status[active as Canonical] === "pending" ? (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Divider & Thumb */}
|
||||
<div
|
||||
className="slider-handle"
|
||||
style={{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties}
|
||||
>
|
||||
<div className="slider-thumb">
|
||||
<div className="w-1.5 h-4 bg-white/80 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Method options – EVEN GRID under the preview */}
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{METHODS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${
|
||||
active === key ? "border-blue-400 bg-blue-500/20" : "border-zinc-700 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
onClick={() => setActive(key)}
|
||||
disabled={!file}
|
||||
title={!file ? "Select a file first" : label}
|
||||
>
|
||||
<StatusDot s={status[key]} />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions & global status */}
|
||||
<div className="mt-4 flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={startAll}
|
||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 flex items-center gap-2 order-0"
|
||||
disabled={!file || pendingCount > 0}
|
||||
title={!file ? "Select a file first" : pendingCount > 0 ? "Running…" : "Run all methods"}
|
||||
>
|
||||
{pendingCount > 0 && <Loader2 className="animate-spin w-4 h-4" />}{" "}
|
||||
{pendingCount > 0 ? `Running… ${doneCount}/${METHODS.length}` : "Run all methods"}
|
||||
</button>
|
||||
|
||||
{/* GPU-safe toggle: sits next to Run All on mobile; keeps position naturally on larger screens */}
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gpuSafe}
|
||||
onChange={(e) => setGpuSafe(e.target.checked)}
|
||||
/>{" "}
|
||||
GPU-safe mode
|
||||
</label>
|
||||
|
||||
<div className="text-zinc-400 text-sm order-2">
|
||||
{file ? (
|
||||
pendingCount > 0 ? (
|
||||
<span>
|
||||
Processing… {doneCount}/{METHODS.length} finished
|
||||
</span>
|
||||
) : doneCount > 0 ? (
|
||||
<span>Done: {doneCount} methods succeeded</span>
|
||||
) : (
|
||||
<span>Ready. Click Run all methods</span>
|
||||
)
|
||||
) : (
|
||||
<span>Drop an image to begin</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-side controls collapse under on mobile */}
|
||||
<div className="sm:ml-auto flex items-center gap-3 w-full sm:w-auto order-3">
|
||||
{/* On mobile, let the slider span full width to avoid crowding */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={reveal}
|
||||
onChange={(e) => setReveal(parseInt(e.target.value, 10))}
|
||||
className="w-full sm:w-56"
|
||||
title="Slide to compare before/after"
|
||||
/>
|
||||
<button
|
||||
onClick={download}
|
||||
disabled={!canDownload}
|
||||
className={`px-3 py-1 rounded-md border ${
|
||||
canDownload
|
||||
? "border-emerald-600 bg-emerald-600/20 hover:bg-emerald-600/30"
|
||||
: "border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
// app/buying-guide/finder/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { Answers, LaserType } from "@/lib/laser-finder";
|
||||
import { scoreAnswers, LASER_LABEL, TYPE_INFO } from "@/lib/laser-finder";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LaserFinderPage() {
|
||||
const [result, setResult] = useState<{
|
||||
top: LaserType[];
|
||||
score: Record<LaserType, number>;
|
||||
why: Record<LaserType, string[]>;
|
||||
} | null>(null);
|
||||
|
||||
const { register, handleSubmit, reset, watch } = useForm<Answers>({
|
||||
defaultValues: {
|
||||
materials: [],
|
||||
operations: [],
|
||||
part_size: "medium",
|
||||
detail: "medium",
|
||||
throughput: "medium",
|
||||
budget: "mid",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (vals: Answers) => {
|
||||
const { ranked, score, why } = scoreAnswers(vals);
|
||||
setResult({ top: ranked.slice(0, 2), score, why });
|
||||
// (Optional) later: POST vals to Directus for analytics
|
||||
};
|
||||
|
||||
const selectedMaterials = watch("materials");
|
||||
const selectedOps = watch("operations");
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Laser Type Finder</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Answer a few questions and we’ll suggest the best laser <em>types</em> for your work
|
||||
with clear use-cases, materials, and cautions. No product pitches—just guidance.
|
||||
</p>
|
||||
|
||||
{!result && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Materials */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Materials (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["metals_bare", "Bare metals"],
|
||||
["metals_coated", "Coated/painted metals"],
|
||||
["plastics", "Plastics"],
|
||||
["wood_paper_leather", "Wood, paper, leather"],
|
||||
["glass_ceramic", "Glass / ceramic"],
|
||||
["stone", "Stone"],
|
||||
["textiles", "Textiles"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("materials")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedMaterials?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: choose at least one material for a better match.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Operations */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Typical operations (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["deep_mark_metal", "Deep mark on metal"],
|
||||
["color_mark_stainless", "Color mark stainless"],
|
||||
["fine_engraving", "Fine engraving (small features)"],
|
||||
["photo_engrave", "Photo engraving"],
|
||||
["cut_nonmetals_thick", "Cut thick non-metals (e.g., 6+ mm acrylic/wood)"],
|
||||
["cut_nonmetals_thin", "Cut thin non-metals"],
|
||||
["mark_coated", "Mark coated items"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("operations")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedOps?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: pick one or more to sharpen the recommendation.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Size / Detail / Speed / Budget */}
|
||||
<div className="grid sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Part size</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("part_size")}>
|
||||
<option value="small">Small (≤ 200 mm)</option>
|
||||
<option value="medium">Medium (≤ 600 mm)</option>
|
||||
<option value="large">Large (> 600 mm)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Detail</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("detail")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="micro">Micro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Throughput</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("throughput")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Budget</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("budget")}>
|
||||
<option value="low">Lower</option>
|
||||
<option value="mid">Mid</option>
|
||||
<option value="high">Higher</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90" type="submit">
|
||||
Get recommendations
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!!result && (
|
||||
<div className="space-y-6">
|
||||
<ResultCard
|
||||
title="Top recommendation"
|
||||
type={result.top[0]}
|
||||
why={result.why[result.top[0]]}
|
||||
/>
|
||||
|
||||
{result.top[1] && (
|
||||
<ResultCard
|
||||
title="Alternative to consider"
|
||||
type={result.top[1]}
|
||||
why={result.why[result.top[1]]}
|
||||
secondary
|
||||
/>
|
||||
)}
|
||||
|
||||
<CompareMatrix />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded hover:bg-muted" onClick={() => setResult(null)}>
|
||||
Start over
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 border rounded hover:bg-muted"
|
||||
onClick={() => { reset(); setResult(null); }}
|
||||
>
|
||||
New answers
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({
|
||||
title,
|
||||
type,
|
||||
why,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
type: LaserType;
|
||||
why?: string[];
|
||||
secondary?: boolean;
|
||||
}) {
|
||||
const info = TYPE_INFO[type];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{!secondary && <span className="text-xs rounded bg-muted px-2 py-0.5">Best match</span>}
|
||||
</div>
|
||||
<div className="text-base font-medium">{LASER_LABEL[type]}</div>
|
||||
<p className="text-sm text-muted-foreground">{info.summary}</p>
|
||||
|
||||
{!!why?.length && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Why this fits</div>
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
{why.slice(0, 5).map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 text-sm">
|
||||
<TagList label="Best for" items={info.bestFor} />
|
||||
<TagList label="Materials" items={info.materials} />
|
||||
<TagList label="Cautions" items={info.cautions} tone="warn" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Link className="text-sm underline hover:no-underline" href={info.learnLink}>
|
||||
See community settings
|
||||
</Link>
|
||||
<Link className="text-sm underline hover:no-underline" href="/submit/settings?target=settings_fiber">
|
||||
Suggest new settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({
|
||||
label,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
tone?: "warn";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">{label}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-xs px-2 py-1 rounded border ${
|
||||
tone === "warn" ? "bg-amber-50 border-amber-200" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareMatrix() {
|
||||
const rows: Array<{
|
||||
k: LaserType;
|
||||
label: string;
|
||||
best: string[];
|
||||
ok: string[];
|
||||
avoid: string[];
|
||||
}> = [
|
||||
{
|
||||
k: "fiber",
|
||||
label: LASER_LABEL.fiber,
|
||||
best: ["Bare metals", "Deep metal engrave", "Color marking stainless"],
|
||||
ok: ["Some coated items", "Some plastics w/ additives"],
|
||||
avoid: ["Thick organics cutting"],
|
||||
},
|
||||
{
|
||||
k: "co2_gantry",
|
||||
label: LASER_LABEL.co2_gantry,
|
||||
best: ["Acrylic cutting", "Wood cutting/engraving", "Large panels"],
|
||||
ok: ["Leathers, textiles, rubber"],
|
||||
avoid: ["Bare metals (no coat)"],
|
||||
},
|
||||
{
|
||||
k: "co2_galvo",
|
||||
label: LASER_LABEL.co2_galvo,
|
||||
best: ["Fast marking organics", "Photo engraving organics"],
|
||||
ok: ["Coated metals/non-metals"],
|
||||
avoid: ["Thick sheet cutting", "Large panels"],
|
||||
},
|
||||
{
|
||||
k: "uv",
|
||||
label: LASER_LABEL.uv,
|
||||
best: ["Micro features", "Glass/ceramic/plastics marking"],
|
||||
ok: ["Fine logos on coated metals"],
|
||||
avoid: ["Thick cutting"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4">
|
||||
<div className="text-sm font-medium mb-2">Compare laser types</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Best for</th>
|
||||
<th className="py-2 pr-3">Okay for</th>
|
||||
<th className="py-2">Not ideal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className="border-b last:border-0 align-top">
|
||||
<td className="py-2 pr-3 font-medium">{r.label}</td>
|
||||
<td className="py-2 pr-3">{r.best.join(", ")}</td>
|
||||
<td className="py-2 pr-3">{r.ok.join(", ")}</td>
|
||||
<td className="py-2">{r.avoid.join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
product_make: string;
|
||||
product_model: string;
|
||||
product_price?: string;
|
||||
review_overview_text?: string;
|
||||
bg_entry_sub_cat?: number;
|
||||
bg_entry_cat?: number;
|
||||
index?: {
|
||||
id: string;
|
||||
filename_disk?: string;
|
||||
type?: string;
|
||||
};
|
||||
header?: {
|
||||
id: string;
|
||||
filename_disk?: string;
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SubCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
bg_entry_cat?: number;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function BuyingGuidePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get("query") || "";
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [subcategories, setSubcategories] = useState<SubCategory[]>([]);
|
||||
const [selectedCat, setSelectedCat] = useState("");
|
||||
const [selectedSubCat, setSelectedSubCat] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [entriesRes, catRes, subCatRes] = await Promise.all([
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_sub_cat?fields=id,name,bg_entry_cat&limit=-1`),
|
||||
]);
|
||||
|
||||
const [entriesData, catData, subCatData] = await Promise.all([
|
||||
entriesRes.json(),
|
||||
catRes.json(),
|
||||
subCatRes.json(),
|
||||
]);
|
||||
|
||||
setEntries(entriesData?.data || []);
|
||||
setCategories(catData?.data || []);
|
||||
setSubcategories(subCatData?.data || []);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Error fetching data:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = normalize(debouncedQuery);
|
||||
return entries.filter((entry) => {
|
||||
const catMatch = selectedCat ? entry.bg_entry_cat === parseInt(selectedCat) : true;
|
||||
const subCatMatch = selectedSubCat ? entry.bg_entry_sub_cat === parseInt(selectedSubCat) : true;
|
||||
const searchMatch = q
|
||||
? [entry.product_make, entry.product_model, entry.review_overview_text].some((field) =>
|
||||
normalize(field || "").includes(q)
|
||||
)
|
||||
: true;
|
||||
return catMatch && subCatMatch && searchMatch;
|
||||
});
|
||||
}, [entries, debouncedQuery, selectedCat, selectedSubCat]);
|
||||
|
||||
const filteredSubcategories = useMemo(() => {
|
||||
return selectedCat
|
||||
? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat))
|
||||
: subcategories;
|
||||
}, [subcategories, selectedCat]);
|
||||
|
||||
const featuredEntry = useMemo(() => {
|
||||
if (!entries.length) return null;
|
||||
const randomIndex = Math.floor(Math.random() * entries.length);
|
||||
return entries[randomIndex];
|
||||
}, [entries]);
|
||||
|
||||
const secondFeaturedEntry = useMemo(() => {
|
||||
if (entries.length < 2) return null;
|
||||
let secondIndex = Math.floor(Math.random() * entries.length);
|
||||
while (entries[secondIndex].id === featuredEntry?.id) {
|
||||
secondIndex = Math.floor(Math.random() * entries.length);
|
||||
}
|
||||
return entries[secondIndex];
|
||||
}, [entries, featuredEntry]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
mark {
|
||||
background: #ffde59;
|
||||
color: #242424;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
background-color: #242424;
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
height: 150px;
|
||||
}
|
||||
.entry-image {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.entry-content {
|
||||
padding: 0.75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.truncate-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h1 className="text-2xl font-bold mb-2">Buying Guide</h1>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2 mb-2"
|
||||
value={selectedCat}
|
||||
onChange={(e) => {
|
||||
setSelectedCat(e.target.value);
|
||||
setSelectedSubCat("");
|
||||
}}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id.toString()}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2 mb-2"
|
||||
value={selectedSubCat}
|
||||
onChange={(e) => setSelectedSubCat(e.target.value)}
|
||||
>
|
||||
<option value="">All Subcategories</option>
|
||||
{filteredSubcategories.map((sub) => (
|
||||
<option key={sub.id} value={sub.id.toString()}>
|
||||
{sub.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search products by make, model, etc..."
|
||||
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Discover reviewed laser products and accessories.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
|
||||
>
|
||||
← Back to Main Menu
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{[featuredEntry, secondFeaturedEntry].map((entry, idx) => (
|
||||
entry && (
|
||||
<div key={idx} className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Featured Product</h2>
|
||||
{entry.header?.filename_disk ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${entry.header.filename_disk}`}
|
||||
alt="Header image"
|
||||
width={800}
|
||||
height={100}
|
||||
className="w-full h-[100px] object-cover mb-2 rounded-md"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[100px] bg-zinc-800 flex items-center justify-center text-zinc-400 text-sm rounded-md mb-2">
|
||||
No Header
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/buying-guide/product/${entry.id}`}
|
||||
className="text-accent font-semibold text-lg hover:underline"
|
||||
>
|
||||
{entry.product_make} {entry.product_model}
|
||||
</Link>
|
||||
{entry.product_price && (
|
||||
<p className="text-sm text-white">Starting at {entry.product_price}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{entry.review_overview_text?.slice(0, 140)}...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Popular Categories</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{categories.slice(0, 5).map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCat(cat.id.toString());
|
||||
setSelectedSubCat("");
|
||||
}}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Recently Added</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{entries.slice(0, 3).map((e) => (
|
||||
<li key={e.id}>
|
||||
<Link href={`/buying-guide/product/${e.id}`} className="text-accent hover:underline">
|
||||
{e.product_make} {e.product_model}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations. Use the filters and search to find what you’re looking for!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-8 border-border" />
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading entries...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No entries found.</p>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{filtered.map((entry) => {
|
||||
const filename = entry.index?.filename_disk;
|
||||
return (
|
||||
<div key={entry.id} className="entry-card">
|
||||
{filename ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${filename}`}
|
||||
alt={`${entry.product_make} ${entry.product_model}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="entry-image"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
<div className="entry-content">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground truncate-title">
|
||||
{entry.product_make}
|
||||
</p>
|
||||
<Link
|
||||
href={`/buying-guide/product/${entry.id}`}
|
||||
className="text-lg font-semibold text-accent underline truncate-title"
|
||||
title={entry.product_model}
|
||||
>
|
||||
{entry.product_model}
|
||||
</Link>
|
||||
{entry.product_price !== undefined && (
|
||||
<p className="text-sm text-foreground mt-1 font-medium">
|
||||
Starting at {entry.product_price}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{entry.review_overview_text?.slice(0, 120)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
// app/buying-guide/product/[id]/page.tsx
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
|
||||
|
||||
async function getEntry(id: string) {
|
||||
const res = await fetch(
|
||||
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
|
||||
{
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
console.error(`Failed to fetch entry: ${error}`);
|
||||
throw new Error(`Error fetching entry ${id}`);
|
||||
}
|
||||
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function ProductDetail({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
const entry = await getEntry(id);
|
||||
|
||||
const avgScore =
|
||||
entry?.scores?.length > 0
|
||||
? (
|
||||
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
|
||||
entry.scores.length
|
||||
).toFixed(1)
|
||||
: "N/A";
|
||||
|
||||
const headerUrl = entry.header?.id
|
||||
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header Banner */}
|
||||
{headerUrl && (
|
||||
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
|
||||
<img
|
||||
src={headerUrl}
|
||||
alt="Header Image"
|
||||
className="object-cover w-full h-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
|
||||
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entry.product_price && (
|
||||
<p className="text-lg text-white font-medium mt-1">
|
||||
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/buying-guide"
|
||||
className="text-sm text-blue-500 underline mt-2 inline-block"
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links & Score Summary */}
|
||||
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{Array.isArray(entry.links) && entry.links.length > 0 && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Links</h3>
|
||||
<ul className="list-disc ml-6 space-y-1">
|
||||
{entry.links.map((link: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 underline"
|
||||
>
|
||||
{link.text || link.url}
|
||||
</a>
|
||||
{link.target && (
|
||||
<span className="text-sm text-gray-500"> ({link.target})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
|
||||
<ul className="space-y-1">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<li key={idx} className="flex justify-between">
|
||||
<span>{s.cat}</span>
|
||||
<span className="font-semibold">{s.value}/10</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{entry.review_overview_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Overview</h3>
|
||||
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{entry.review_intro_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
|
||||
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Scores */}
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<div key={idx} className="p-4 rounded border">
|
||||
<p className="text-xl font-semibold">
|
||||
{s.cat} – <span className="text-blue-600">{s.value}/10</span>
|
||||
</p>
|
||||
<div className="text-sm text-gray-400">
|
||||
<ReactMarkdown>{s.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendation */}
|
||||
{entry.rec_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
|
||||
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates */}
|
||||
{entry.updates && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Updates</h3>
|
||||
<ReactMarkdown>{entry.updates}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{entry.video_review_url && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<iframe
|
||||
src={entry.video_review_url.replace("watch?v=", "embed/")}
|
||||
className="w-full h-96 rounded"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
// app/buying-guide/product/[id]/page.tsx
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
|
||||
|
||||
async function getEntry(id: string) {
|
||||
const res = await fetch(
|
||||
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
|
||||
{
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
console.error(`Failed to fetch entry: ${error}`);
|
||||
throw new Error(`Error fetching entry ${id}`);
|
||||
}
|
||||
|
||||
const { data } = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function ProductDetail({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = params.id;
|
||||
const entry = await getEntry(id);
|
||||
|
||||
const avgScore =
|
||||
entry?.scores?.length > 0
|
||||
? (
|
||||
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
|
||||
entry.scores.length
|
||||
).toFixed(1)
|
||||
: "N/A";
|
||||
|
||||
const headerUrl = entry.header?.id
|
||||
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header Banner */}
|
||||
{headerUrl && (
|
||||
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
|
||||
<img
|
||||
src={headerUrl}
|
||||
alt="Header Image"
|
||||
className="object-cover w-full h-full rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
|
||||
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entry.product_price && (
|
||||
<p className="text-lg text-white font-medium mt-1">
|
||||
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/buying-guide"
|
||||
className="text-sm text-blue-500 underline mt-2 inline-block"
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links & Score Summary */}
|
||||
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{Array.isArray(entry.links) && entry.links.length > 0 && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Links</h3>
|
||||
<ul className="list-disc ml-6 space-y-1">
|
||||
{entry.links.map((link: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 underline"
|
||||
>
|
||||
{link.text || link.url}
|
||||
</a>
|
||||
{link.target && (
|
||||
<span className="text-sm text-gray-500"> ({link.target})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="md:w-1/2">
|
||||
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
|
||||
<ul className="space-y-1">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<li key={idx} className="flex justify-between">
|
||||
<span>{s.cat}</span>
|
||||
<span className="font-semibold">{s.value}/10</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{entry.review_overview_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Overview</h3>
|
||||
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
{entry.review_intro_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
|
||||
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Scores */}
|
||||
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
|
||||
<div className="space-y-4">
|
||||
{entry.scores.map((s: any, idx: number) => (
|
||||
<div key={idx} className="p-4 rounded border">
|
||||
<p className="text-xl font-semibold">
|
||||
{s.cat} – <span className="text-blue-600">{s.value}/10</span>
|
||||
</p>
|
||||
<div className="text-sm text-gray-400">
|
||||
<ReactMarkdown>{s.body}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendation */}
|
||||
{entry.rec_text && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
|
||||
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates */}
|
||||
{entry.updates && (
|
||||
<div className="prose max-w-none">
|
||||
<h3 className="text-xl font-semibold mb-2">Updates</h3>
|
||||
<ReactMarkdown>{entry.updates}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{entry.video_review_url && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<iframe
|
||||
src={entry.video_review_url.replace("watch?v=", "embed/")}
|
||||
className="w-full h-96 rounded"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
|
@ -1,17 +0,0 @@
|
|||
import { Suspense } from "react";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<div className="p-4">
|
||||
<a
|
||||
href="https://makearmy.io"
|
||||
className="inline-block mb-4 px-4 py-2 bg-accent text-background rounded-md text-sm"
|
||||
>
|
||||
← Back to Main Menu
|
||||
</a>
|
||||
</div>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
app = FastAPI()
|
||||
FILES_ROOT = Path("/var/www/lasereverything.net.db/files").resolve()
|
||||
|
||||
@app.get("/list-files")
|
||||
def list_files(
|
||||
path: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
):
|
||||
base_path = (FILES_ROOT / path).resolve()
|
||||
|
||||
if not base_path.is_dir() or not str(base_path).startswith(str(FILES_ROOT)):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
entries = []
|
||||
for item in base_path.iterdir():
|
||||
if item.is_dir():
|
||||
entries.append(f"{item.name}/")
|
||||
else:
|
||||
entries.append(item.name)
|
||||
|
||||
entries.sort()
|
||||
paginated = entries[offset:offset + limit]
|
||||
|
||||
return {
|
||||
"total": len(entries),
|
||||
"items": paginated
|
||||
}
|
||||
|
||||
@app.get("/download/{file_path:path}")
|
||||
def download_file(file_path: str):
|
||||
full_path = (FILES_ROOT / file_path).resolve()
|
||||
|
||||
if not full_path.is_file() or not str(full_path).startswith(str(FILES_ROOT)):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(full_path, filename=full_path.name)
|
||||
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
// /var/www/makearmy.io/app/app/files/page.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
export default function FilesPage() {
|
||||
const search = useSearchParams();
|
||||
const router = useRouter();
|
||||
const path = useMemo(() => search.get("path") || "/", [search]);
|
||||
|
||||
const [items, setItems] = useState<FileItem[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files/list?path=${encodeURIComponent(path)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setError(`HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!cancelled) setItems(json.items || []);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || String(e));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path]);
|
||||
|
||||
const upPath = useMemo(() => {
|
||||
if (path === "/") return null;
|
||||
const parts = path.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
return "/" + parts.join("/");
|
||||
}, [path]);
|
||||
|
||||
return (
|
||||
<div className="p-6 text-sm">
|
||||
|
||||
<div className="mb-3">
|
||||
<span className="opacity-70 mr-1">Path:</span>
|
||||
<code>{path}</code>
|
||||
{upPath && (
|
||||
<>
|
||||
<span className="mx-2 opacity-50">•</span>
|
||||
<Link href={`/files?path=${encodeURIComponent(upPath)}`}>
|
||||
Up one level
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <div>Loading…</div>}
|
||||
{error && (
|
||||
<div className="bg-red-900/60 text-red-200 p-3 rounded border border-red-800">
|
||||
Error loading files: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && items && (
|
||||
<table className="w-full text-left mt-3 border-collapse">
|
||||
<thead className="opacity-70">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Name</th>
|
||||
<th className="py-2 pr-4">Type</th>
|
||||
<th className="py-2 pr-4">Size</th>
|
||||
<th className="py-2 pr-4">Modified</th>
|
||||
<th className="py-2 pr-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => {
|
||||
const href = it.isDir
|
||||
? `/files?path=${encodeURIComponent(
|
||||
(path.endsWith("/") ? path : path + "/") + it.name
|
||||
)}`
|
||||
: `/api/files/raw?path=${encodeURIComponent(
|
||||
(path.endsWith("/") ? path : path + "/") + it.name
|
||||
)}`;
|
||||
const dl = it.isDir
|
||||
? null
|
||||
: `/api/files/download?path=${encodeURIComponent(
|
||||
(path.endsWith("/") ? path : path + "/") + it.name
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<tr key={it.name} className="border-t border-white/10">
|
||||
<td className="py-2 pr-4">
|
||||
<Link href={href}>{it.name}</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4">{it.isDir ? "Dir" : "File"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
{it.isDir ? "-" : `${it.size.toLocaleString()} B`}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{new Date(it.mtime).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
{!it.isDir && dl && (
|
||||
<a href={dl} className="underline">
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
// /var/www/makearmy.io/app/app/laser-toolkit/_lib/conversions.ts
|
||||
|
||||
// ---------- DPI / LPI / DPCM ----------
|
||||
export function dpiToLpi(dpi: number) {
|
||||
return dpi; // 1:1 if treating “lines” as rows in raster (common convention)
|
||||
}
|
||||
export function dpiToDpcm(dpi: number) {
|
||||
return dpi / 2.54;
|
||||
}
|
||||
export function lpiToDpi(lpi: number) {
|
||||
return lpi; // same note as above
|
||||
}
|
||||
export function lpiToDpcm(lpi: number) {
|
||||
return lpi / 2.54;
|
||||
}
|
||||
export function dpcmToDpi(dpcm: number) {
|
||||
return dpcm * 2.54;
|
||||
}
|
||||
export function dpcmToLpi(dpcm: number) {
|
||||
return dpcm * 2.54;
|
||||
}
|
||||
|
||||
// ---------- Power & Lens Scaler ----------
|
||||
// Simple “keep energy density roughly constant” heuristic:
|
||||
// newSpeed ≈ oldSpeed * (toPower / fromPower) * (fromField / toField)
|
||||
export function scaleSpeed(
|
||||
oldSpeed_mm_s: number,
|
||||
fromPower_W: number,
|
||||
toPower_W: number,
|
||||
fromField_mm: number,
|
||||
toField_mm: number
|
||||
) {
|
||||
if (fromPower_W <= 0 || toPower_W <= 0 || fromField_mm <= 0 || toField_mm <= 0) {
|
||||
return oldSpeed_mm_s;
|
||||
}
|
||||
const k = (toPower_W / fromPower_W) * (fromField_mm / toField_mm);
|
||||
return oldSpeed_mm_s * k;
|
||||
}
|
||||
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import ToolShell from "@/components/toolkit/ToolShell";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function num(v: string) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
// Spot diameter (µm) ≈ 1.27 * M² * λ(µm) * f(mm) / D(mm)
|
||||
export default function Page() {
|
||||
const [lambdaNm, setLambdaNm] = useState("1064"); // nm (default fiber)
|
||||
const [focalMm, setFocalMm] = useState("160"); // mm
|
||||
const [beamDm, setBeamDm] = useState("6"); // mm (input beam diameter at lens)
|
||||
const [m2, setM2] = useState("1.3");
|
||||
|
||||
const dUm = useMemo(() => {
|
||||
const lamUm = num(lambdaNm) / 1000; // convert nm -> µm
|
||||
const f = num(focalMm);
|
||||
const D = num(beamDm);
|
||||
const M2 = Math.max(1, num(m2));
|
||||
if (lamUm <= 0 || f <= 0 || D <= 0) return 0;
|
||||
return 1.27 * M2 * lamUm * (f / D);
|
||||
}, [lambdaNm, focalMm, beamDm, m2]);
|
||||
|
||||
const dMm = dUm / 1000;
|
||||
|
||||
return (
|
||||
<ToolShell title="Beam Spot Size">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Inputs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-4">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Wavelength (nm)</div>
|
||||
<Input value={lambdaNm} onChange={(e) => setLambdaNm(e.target.value)} />
|
||||
<div className="mt-1 text-[11px] text-muted-foreground space-x-2">
|
||||
<button type="button" className="underline" onClick={() => setLambdaNm("1064")}>
|
||||
Fiber (1064 nm)
|
||||
</button>
|
||||
<button type="button" className="underline" onClick={() => setLambdaNm("10600")}>
|
||||
CO₂ (10600 nm)
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Focal length (mm)</div>
|
||||
<Input value={focalMm} onChange={(e) => setFocalMm(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Beam Ø @ lens (mm)</div>
|
||||
<Input value={beamDm} onChange={(e) => setBeamDm(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">M²</div>
|
||||
<Input value={m2} onChange={(e) => setM2(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Result</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Spot diameter</div>
|
||||
<div className="text-lg">{dMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{dUm.toFixed(2)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Spot radius</div>
|
||||
<div className="text-lg">{(dMm / 2).toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{(dUm / 2).toFixed(2)} µm</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import ToolShell from "@/components/toolkit/ToolShell";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function num(v: string) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [dpi, setDpi] = useState("300");
|
||||
const [lpi, setLpi] = useState("300");
|
||||
const [dpcm, setDpcm] = useState("118.11");
|
||||
|
||||
const [active, setActive] = useState<"dpi" | "lpi" | "dpcm">("dpi");
|
||||
|
||||
// keep all three in sync based on the most recently edited field
|
||||
useEffect(() => {
|
||||
const D = num(dpi), L = num(lpi), C = num(dpcm);
|
||||
if (active === "dpi") {
|
||||
const d = Math.max(1e-9, D);
|
||||
setDpcm((d / 2.54).toFixed(5));
|
||||
setLpi(D.toFixed(2)); // LPI≈DPI for raster row spacing (workflow convention)
|
||||
} else if (active === "lpi") {
|
||||
const l = Math.max(1e-9, L);
|
||||
setDpi(L.toFixed(2));
|
||||
setDpcm((L / 2.54).toFixed(5));
|
||||
} else {
|
||||
const c = Math.max(1e-9, C);
|
||||
setDpi((c * 2.54).toFixed(2));
|
||||
setLpi((c * 2.54).toFixed(2));
|
||||
}
|
||||
}, [dpi, lpi, dpcm, active]);
|
||||
|
||||
const gapFromDpiMm = 25.4 / Math.max(1e-9, num(dpi));
|
||||
const gapFromLpiMm = 25.4 / Math.max(1e-9, num(lpi));
|
||||
|
||||
return (
|
||||
<ToolShell title="DPI / LPI / DPCM Converter">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Values</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">DPI</div>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={dpi}
|
||||
onChange={(e) => {
|
||||
setActive("dpi");
|
||||
setDpi(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">LPI</div>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={lpi}
|
||||
onChange={(e) => {
|
||||
setActive("lpi");
|
||||
setLpi(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">DPCM</div>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={dpcm}
|
||||
onChange={(e) => {
|
||||
setActive("dpcm");
|
||||
setDpcm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Derived spacing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Pixel/line gap from DPI</div>
|
||||
<div className="text-lg">{gapFromDpiMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{(gapFromDpiMm * 1000).toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Line gap from LPI</div>
|
||||
<div className="text-lg">{gapFromLpiMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{(gapFromLpiMm * 1000).toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Pixels/cm from DPCM</div>
|
||||
<div className="text-lg">{num(dpcm).toFixed(2)} px/cm</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(num(dpcm) * 2.54).toFixed(2)} px/in
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import ToolShell from "@/components/toolkit/ToolShell";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function num(v: string) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const UM_PER_INCH = 25400;
|
||||
|
||||
export default function Page() {
|
||||
const [spotUm, setSpotUm] = useState("60");
|
||||
const [gapUm, setGapUm] = useState("40");
|
||||
const [lpi, setLpi] = useState("635"); // 635 LPI ≈ 40 µm gap
|
||||
|
||||
// Keep gap and LPI linked both ways
|
||||
function onGapChange(v: string) {
|
||||
setGapUm(v);
|
||||
const g = num(v);
|
||||
setLpi(g > 0 ? (UM_PER_INCH / g).toFixed(2) : "");
|
||||
}
|
||||
function onLpiChange(v: string) {
|
||||
setLpi(v);
|
||||
const L = num(v);
|
||||
setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : "");
|
||||
}
|
||||
|
||||
const overlap = useMemo(() => {
|
||||
const d = num(spotUm);
|
||||
const g = num(gapUm);
|
||||
if (d <= 0 || g <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, 100 * (1 - g / d)));
|
||||
}, [spotUm, gapUm]);
|
||||
|
||||
const gapMm = (num(gapUm) / 1000) || 0;
|
||||
const spotMm = (num(spotUm) / 1000) || 0;
|
||||
|
||||
return (
|
||||
<ToolShell title="Hatch Overlap">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Inputs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
|
||||
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Hatch gap (µm)</div>
|
||||
<Input inputMode="decimal" value={gapUm} onChange={(e) => onGapChange(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Hatch LPI</div>
|
||||
<Input inputMode="decimal" value={lpi} onChange={(e) => onLpiChange(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Result</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Overlap</div>
|
||||
<div className="text-lg">{overlap.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Gap</div>
|
||||
<div className="text-lg">{gapMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{num(gapUm).toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Spot Ø</div>
|
||||
<div className="text-lg">{spotMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{num(spotUm).toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">From LPI</div>
|
||||
<div className="text-lg">
|
||||
{(UM_PER_INCH / Math.max(1, num(lpi)) / 1000).toFixed(4)} mm
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(UM_PER_INCH / Math.max(1, num(lpi))).toFixed(1)} µm
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import ToolShell from "@/components/toolkit/ToolShell";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function num(v: string) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function fmtTime(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "0 s";
|
||||
const s = Math.round(seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m}m ${rem}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
const mm = m % 60;
|
||||
return `${h}h ${mm}m`;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [mode, setMode] = useState<"raster" | "vector">("raster");
|
||||
const [passes, setPasses] = useState("1");
|
||||
|
||||
// raster
|
||||
const [width, setWidth] = useState("100"); // mm
|
||||
const [height, setHeight] = useState("100");// mm
|
||||
const [dpi, setDpi] = useState("300");
|
||||
const [speedRaster, setSpeedRaster] = useState("800"); // mm/s
|
||||
const [overheadR, setOverheadR] = useState("1.10"); // factor
|
||||
|
||||
// vector
|
||||
const [length, setLength] = useState("500"); // mm
|
||||
const [speedVector, setSpeedVector] = useState("50"); // mm/s
|
||||
const [overheadV, setOverheadV] = useState("1.05"); // factor
|
||||
|
||||
const computed = useMemo(() => {
|
||||
const p = Math.max(1, Math.round(num(passes)));
|
||||
if (mode === "raster") {
|
||||
const w = num(width), h = num(height), D = num(dpi), v = num(speedRaster), k = Math.max(0.5, num(overheadR));
|
||||
if (w <= 0 || h <= 0 || D <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
|
||||
const gapMm = 25.4 / D;
|
||||
const gapUm = gapMm * 1000;
|
||||
const rows = h / gapMm;
|
||||
const t = rows * (w / v) * p * k;
|
||||
return { t, gapMm, gapUm, rows };
|
||||
} else {
|
||||
const L = num(length), v = num(speedVector), k = Math.max(0.5, num(overheadV));
|
||||
if (L <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
|
||||
const t = (L / v) * Math.max(1, Math.round(num(passes))) * k;
|
||||
return { t, gapMm: 0, gapUm: 0, rows: 0 };
|
||||
}
|
||||
}, [mode, passes, width, height, dpi, speedRaster, overheadR, length, speedVector, overheadV]);
|
||||
|
||||
return (
|
||||
<ToolShell title="Job Time Estimator">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Mode</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-4">
|
||||
<label className="text-[11px] sm:text-xs col-span-2 sm:col-span-1">
|
||||
<div className="mb-1 text-muted-foreground">Type</div>
|
||||
<select
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
value={mode}
|
||||
onChange={(e) => (setMode(e.target.value as any))}
|
||||
>
|
||||
<option value="raster">Raster</option>
|
||||
<option value="vector">Vector</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Passes</div>
|
||||
<Input inputMode="numeric" value={passes} onChange={(e) => setPasses(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{mode === "raster" ? (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Raster Inputs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-5">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Width (mm)</div>
|
||||
<Input value={width} onChange={(e) => setWidth(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Height (mm)</div>
|
||||
<Input value={height} onChange={(e) => setHeight(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">DPI</div>
|
||||
<Input value={dpi} onChange={(e) => setDpi(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
|
||||
<Input value={speedRaster} onChange={(e) => setSpeedRaster(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Overhead factor</div>
|
||||
<Input value={overheadR} onChange={(e) => setOverheadR(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Vector Inputs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Total path length (mm)</div>
|
||||
<Input value={length} onChange={(e) => setLength(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
|
||||
<Input value={speedVector} onChange={(e) => setSpeedVector(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Overhead factor</div>
|
||||
<Input value={overheadV} onChange={(e) => setOverheadV(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Estimate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Estimated time</div>
|
||||
<div className="text-lg">{fmtTime(computed.t)}</div>
|
||||
</div>
|
||||
{mode === "raster" && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Scan gap</div>
|
||||
<div className="text-lg">{computed.gapMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{computed.gapUm.toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Line count</div>
|
||||
<div className="text-lg">{computed.rows.toFixed(0)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footnote */}
|
||||
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
|
||||
<span className="font-semibold">Overhead factor*</span> accounts for real-world slowdowns:
|
||||
acceleration/decelleration, jump moves, polygon delays, laser on/off timing, overscan,
|
||||
bidirectional settle time, and controller latency.{" "}
|
||||
<span className="font-semibold">Typical values:</span> Vector cuts/marks{" "}
|
||||
<span className="font-medium">1.05–1.15</span> (simple paths, long runs closer to 1.05; tiny
|
||||
segments or lots of jumps closer to 1.15). Raster engraving{" "}
|
||||
<span className="font-medium">1.10–1.40</span> (lower DPI and long sweeps near 1.10;
|
||||
very high DPI or short scan width near 1.30–1.40). Galvo systems often have lower overhead
|
||||
at small sizes; gantry systems tend to have higher overhead at high DPI/short strokes.
|
||||
</p>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { Metadata } from "next";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Gauge,
|
||||
Ruler,
|
||||
Timer,
|
||||
Focus,
|
||||
MoveRight,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Laser Toolkit",
|
||||
description: "Quick utilities for scaling settings and converting resolution units.",
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
};
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
slug: "power-lens-scaler",
|
||||
title: "Power & Lens Scaler",
|
||||
description: "Scale speed, power, and frequency when wattage or lens field size changes.",
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
slug: "dpi-lpi-dpcm",
|
||||
title: "DPI ▸ LPI ▸ DPCM",
|
||||
description: "Convert between DPI, LPI, and DPCM. Bidirectional. Assumes LPI≈DPI for raster rows (common workflow).",
|
||||
icon: Ruler,
|
||||
},
|
||||
|
||||
// NEW
|
||||
{
|
||||
slug: "pulse-overlap",
|
||||
title: "Pulse Overlap",
|
||||
description:
|
||||
"Given speed (mm/s), frequency (kHz) and spot size (µm), compute pulse spacing, overlap %, and pulses/mm.",
|
||||
icon: MoveRight,
|
||||
},
|
||||
{
|
||||
slug: "hatch-overlap",
|
||||
title: "Hatch Overlap",
|
||||
description:
|
||||
"Given spot size (µm) and hatch gap (µm) or LPI, compute hatch overlap %. Great for vector fills.",
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
slug: "job-time-estimator",
|
||||
title: "Job Time Estimator",
|
||||
description:
|
||||
"Quick estimate for raster or vector jobs. Uses dimensions, DPI/LPI or path length, speed, passes, and a small overhead factor.",
|
||||
icon: Timer,
|
||||
},
|
||||
{
|
||||
slug: "beam-spot-size",
|
||||
title: "Beam Spot Size",
|
||||
description:
|
||||
"Approximate diffraction-limited spot size from wavelength, focal length, beam diameter, and M².",
|
||||
icon: Focus,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolkitSplash() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Laser Toolkit</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Handy calculators and converters for daily laser work —{" "}
|
||||
<span className="italic">hover for details</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">Back to Main Menu</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid of tools */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TOOLS.map((tool) => (
|
||||
<Link key={tool.slug} href={`/laser-toolkit/${tool.slug}`} className="group">
|
||||
<Card className="relative overflow-hidden transition-shadow hover:shadow-md">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border bg-card p-2">
|
||||
<tool.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base">{tool.title}</CardTitle>
|
||||
|
||||
{/* Full description on hover (no truncation) */}
|
||||
<p
|
||||
className="
|
||||
max-h-0 overflow-hidden text-xs text-muted-foreground opacity-0
|
||||
transition-all duration-200
|
||||
group-hover:max-h-96 group-hover:opacity-100
|
||||
mt-1 whitespace-pre-wrap
|
||||
"
|
||||
>
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import ToolShell from '@/components/toolkit/ToolShell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Mode = 'vector' | 'raster' | 'irradiance' | 'pulse';
|
||||
|
||||
function num(v: string, d = 0): number {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : d;
|
||||
}
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
/** Default curve parameters based on rated power (very rough, editable). */
|
||||
function defaultCurveForRatedW(W: number) {
|
||||
// Peak frequency guess (kHz). Tune these to your hardware fleet.
|
||||
let fPeak = 50;
|
||||
if (W <= 35) fPeak = 25;
|
||||
else if (W <= 60) fPeak = 50;
|
||||
else if (W <= 90) fPeak = 75;
|
||||
else fPeak = 100;
|
||||
|
||||
// Log-normal width parameter (dimensionless). Smaller = narrower peak.
|
||||
const sigma = 0.35;
|
||||
return { fPeak, sigma };
|
||||
}
|
||||
|
||||
/** Log-normal shaped efficiency curve normalized to 1 at fPeak. */
|
||||
function etaOfF(f_kHz: number, fPeak_kHz: number, sigma: number) {
|
||||
const f = Math.max(f_kHz, 0.1);
|
||||
const r = Math.log(f / Math.max(fPeak_kHz, 0.1));
|
||||
const eta = Math.exp(-0.5 * (r / Math.max(sigma, 0.05)) ** 2);
|
||||
// Keep within [0.1, 1] to avoid absurd zeros; adjust if you want tails to hit 0.
|
||||
return clamp(eta, 0.1, 1);
|
||||
}
|
||||
|
||||
/** Area factor from field (proxy for spot area scaling) */
|
||||
function areaFactorFromField(fieldSrc: number, fieldDst: number) {
|
||||
if (fieldSrc <= 0 || fieldDst <= 0) return 1;
|
||||
const r = fieldDst / fieldSrc;
|
||||
return r * r;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// MODE
|
||||
const [mode, setMode] = useState<Mode>('vector');
|
||||
|
||||
// SOURCE machine/lens
|
||||
const [wSrc, setWSrc] = useState('100'); // rated W
|
||||
const [pSrc, setPSrc] = useState('50'); // %
|
||||
const [vSrc, setVSrc] = useState('300'); // mm/s
|
||||
const [hSrc, setHSrc] = useState('0.1'); // mm (raster line spacing)
|
||||
const [fSrc, setFSrc] = useState('30'); // kHz
|
||||
const [tauSrc, setTauSrc] = useState('100'); // ns pulse width
|
||||
const [fieldSrc, setFieldSrc] = useState('110'); // mm
|
||||
|
||||
// DEST machine/lens
|
||||
const [wDst, setWDst] = useState('50'); // rated W
|
||||
const [vDst, setVDst] = useState('300'); // mm/s
|
||||
const [hDst, setHDst] = useState('0.1'); // mm
|
||||
const [fDst, setFDst] = useState('30'); // kHz
|
||||
const [tauDst, setTauDst] = useState('100'); // ns
|
||||
const [fieldDst, setFieldDst] = useState('70'); // mm
|
||||
|
||||
// Curve tuning / advanced
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const srcDefaults = defaultCurveForRatedW(num(wSrc, 50));
|
||||
const dstDefaults = defaultCurveForRatedW(num(wDst, 50));
|
||||
const [fPeakSrc, setFPeakSrc] = useState(String(srcDefaults.fPeak));
|
||||
const [sigmaSrc, setSigmaSrc] = useState(String(srcDefaults.sigma));
|
||||
const [fPeakDst, setFPeakDst] = useState(String(dstDefaults.fPeak));
|
||||
const [sigmaDst, setSigmaDst] = useState(String(dstDefaults.sigma));
|
||||
|
||||
// Prefer adjusting speed/freq instead of exceeding 100% power
|
||||
const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true);
|
||||
|
||||
const result = useMemo(() => {
|
||||
const W1 = Math.max(num(wSrc, 1), 0.1);
|
||||
const W2 = Math.max(num(wDst, 1), 0.1);
|
||||
const p1 = clamp(num(pSrc, 0), 0, 100) / 100; // 0..1
|
||||
const v1 = Math.max(num(vSrc, 0), 0.0001);
|
||||
const v2 = Math.max(num(vDst, 0), 0.0001);
|
||||
const h1 = Math.max(num(hSrc, 0), 0.000001);
|
||||
const h2 = Math.max(num(hDst, 0), 0.000001);
|
||||
const f1k = Math.max(num(fSrc, 0), 0.1);
|
||||
const f2k = Math.max(num(fDst, 0), 0.1);
|
||||
const tau1_ns = Math.max(num(tauSrc, 0), 0.1);
|
||||
const tau2_ns = Math.max(num(tauDst, 0), 0.1);
|
||||
const aFac = areaFactorFromField(num(fieldSrc, 0), num(fieldDst, 0));
|
||||
|
||||
const fpk1 = Math.max(num(fPeakSrc, defaultCurveForRatedW(W1).fPeak), 0.1);
|
||||
const sig1 = Math.max(num(sigmaSrc, defaultCurveForRatedW(W1).sigma), 0.05);
|
||||
const fpk2 = Math.max(num(fPeakDst, defaultCurveForRatedW(W2).fPeak), 0.1);
|
||||
const sig2 = Math.max(num(sigmaDst, defaultCurveForRatedW(W2).sigma), 0.05);
|
||||
|
||||
// Efficiency factors (0..1)
|
||||
const eta1 = etaOfF(f1k, fpk1, sig1);
|
||||
const eta2 = etaOfF(f2k, fpk2, sig2);
|
||||
|
||||
// Effective average power (W) after frequency efficiency
|
||||
const P1eff = W1 * p1 * eta1;
|
||||
|
||||
let p2Frac = p1; // destination power fraction (0..1)
|
||||
let suggestedSpeed: number | undefined;
|
||||
let suggestedFreq_kHz: number | undefined;
|
||||
|
||||
// Helper: compute required P2eff for each match, then map to power%
|
||||
const powerPercentFromEff = (P2effReq: number) => {
|
||||
// P2eff = W2 * p2 * eta2 => p2 = P2eff / (W2*eta2)
|
||||
return P2effReq / (W2 * eta2);
|
||||
};
|
||||
|
||||
if (mode === 'vector') {
|
||||
// Match energy per length: P1eff / v1 = P2eff / v2
|
||||
const P2effReq = P1eff * (v2 / v1);
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1
|
||||
p2Frac = 1;
|
||||
}
|
||||
} else if (mode === 'raster') {
|
||||
// Match energy per area: P1eff/(v1*h1) = P2eff/(v2*h2)
|
||||
const P2effReq = P1eff * ((v2 * h2) / (v1 * h1));
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1);
|
||||
p2Frac = 1;
|
||||
}
|
||||
} else if (mode === 'irradiance') {
|
||||
// Match irradiance: (P1eff/A1) = (P2eff/A2) => P2eff = P1eff*(A2/A1)
|
||||
const P2effReq = P1eff * aFac;
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
// no speed suggestion; consider lens/field change if >100%
|
||||
} else if (mode === 'pulse') {
|
||||
// Match pulse energy: Ep1 = P1eff / f1 (kHz → Hz)
|
||||
const f1 = f1k * 1e3, f2 = f2k * 1e3;
|
||||
const Ep1 = P1eff / f1; // J
|
||||
// Require P2eff = Ep1 * f2
|
||||
const P2effReq = Ep1 * f2;
|
||||
p2Frac = powerPercentFromEff(P2effReq);
|
||||
|
||||
if (preferSpeedAdjust && p2Frac > 1) {
|
||||
// Suggest lowering f2 to keep p2<=1: P2eff_max = W2*eta2*1
|
||||
// f2_req = P2eff_max / Ep1
|
||||
const f2_req = (W2 * eta2) / Ep1; // Hz
|
||||
suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1);
|
||||
p2Frac = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute pulse metrics (for display) using **destination** settings
|
||||
const p2Clamped = clamp(p2Frac, 0, 2);
|
||||
const P2eff = W2 * p2Clamped * eta2;
|
||||
const f2Hz = f2k * 1e3;
|
||||
const tau2_s = tau2_ns * 1e-9;
|
||||
const Ep2 = P2eff / f2Hz; // J
|
||||
const Ppeak2 = Ep2 / Math.max(tau2_s, 1e-12); // W, shape factor ~1 assumed
|
||||
|
||||
return {
|
||||
p2Percent: clamp(p2Clamped * 100, 0, 200),
|
||||
suggestedSpeed,
|
||||
suggestedFreq_kHz,
|
||||
eta1,
|
||||
eta2,
|
||||
P1eff,
|
||||
P2eff,
|
||||
Ep2,
|
||||
Ppeak2,
|
||||
aFac,
|
||||
};
|
||||
}, [
|
||||
mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst,
|
||||
fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolShell
|
||||
title="Power, Frequency & Lens Scaler"
|
||||
description="Match settings across different lasers and lenses using effective power with a frequency efficiency curve. Includes pulse width to report pulse energy and peak power."
|
||||
>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Match Mode</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-sm">Quantity to Match</Label>
|
||||
<Select value={mode} onValueChange={(v: Mode) => setMode(v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Mode" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vector">Vector: Energy per length (J/mm)</SelectItem>
|
||||
<SelectItem value="raster">Raster: Energy per area (J/mm²)</SelectItem>
|
||||
<SelectItem value="irradiance">Irradiance: W/mm² (spot/field)</SelectItem>
|
||||
<SelectItem value="pulse">Pulse energy: J (fiber)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
id="preferSpeed"
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={preferSpeedAdjust}
|
||||
onChange={(e) => setPreferSpeedAdjust(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">If Power % > 100, prefer adjusting speed/frequency</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Source */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Source (what you have)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Rated power (W)</Label>
|
||||
<Input value={wSrc} onChange={(e) => setWSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Power (%)</Label>
|
||||
<Input value={pSrc} onChange={(e) => setPSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Frequency (kHz)</Label>
|
||||
<Input value={fSrc} onChange={(e) => setFSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Pulse width (ns)</Label>
|
||||
<Input value={tauSrc} onChange={(e) => setTauSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Speed (mm/s)</Label>
|
||||
<Input value={vSrc} onChange={(e) => setVSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Line spacing h (mm)</Label>
|
||||
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Lens field size (mm)</Label>
|
||||
<Input value={fieldSrc} onChange={(e) => setFieldSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<button
|
||||
className="text-xs underline text-muted-foreground"
|
||||
onClick={() => setAdvanced((s) => !s)}
|
||||
>
|
||||
{advanced ? 'Hide' : 'Show'} advanced frequency curve
|
||||
</button>
|
||||
<div className={cn('mt-3 grid gap-4 md:grid-cols-3', advanced ? 'block' : 'hidden')}>
|
||||
<div>
|
||||
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
|
||||
<Input value={fPeakSrc} onChange={(e) => setFPeakSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Curve width σ (log-normal)</Label>
|
||||
<Input value={sigmaSrc} onChange={(e) => setSigmaSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className="flex items-end text-xs text-muted-foreground">
|
||||
η(f) is log-normal; 1.0 at fₚ, rolls off by σ.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Destination */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Destination (what you want to run on)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Rated power (W)</Label>
|
||||
<Input value={wDst} onChange={(e) => setWDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Frequency (kHz)</Label>
|
||||
<Input value={fDst} onChange={(e) => setFDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Pulse width (ns)</Label>
|
||||
<Input value={tauDst} onChange={(e) => setTauDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Speed (mm/s)</Label>
|
||||
<Input value={vDst} onChange={(e) => setVDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Line spacing h (mm)</Label>
|
||||
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Lens field size (mm)</Label>
|
||||
<Input value={fieldDst} onChange={(e) => setFieldDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className={cn('pt-0', advanced ? 'block' : 'hidden')}>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
|
||||
<Input value={fPeakDst} onChange={(e) => setFPeakDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Curve width σ (log-normal)</Label>
|
||||
<Input value={sigmaDst} onChange={(e) => setSigmaDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className="flex items-end text-xs text-muted-foreground">
|
||||
Adjust if you know your machine’s real power–frequency curve.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Result</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold">
|
||||
Suggested Power (dest): {result.p2Percent.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
{typeof result.suggestedSpeed === 'number' && mode !== 'pulse' && (
|
||||
<p className="text-sm">
|
||||
To keep Power ≤ 100%, try destination speed ≈{' '}
|
||||
<span className="font-medium">{result.suggestedSpeed.toFixed(1)} mm/s</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{typeof result.suggestedFreq_kHz === 'number' && mode === 'pulse' && (
|
||||
<p className="text-sm">
|
||||
To keep Power ≤ 100%, try destination frequency ≈{' '}
|
||||
<span className="font-medium">{result.suggestedFreq_kHz.toFixed(0)} kHz</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">η(f) source / dest</div>
|
||||
<div className="font-medium">{result.eta1.toFixed(3)} / {result.eta2.toFixed(3)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Dest pulse energy</div>
|
||||
<div className="font-medium">
|
||||
{(result.Ep2 >= 1e-3 ? (result.Ep2 * 1e3).toFixed(3) + ' mJ' : (result.Ep2 * 1e6).toFixed(1) + ' µJ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Dest peak power</div>
|
||||
<div className="font-medium">{(result.Ppeak2 / 1000).toFixed(1)} kW</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Assumptions: Effective power includes a frequency efficiency factor η(f). Peak power uses a rectangular pulse
|
||||
approximation (shape factor ≈ 1). For real MOPA sources, pulse shape and
|
||||
true power–frequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import ToolShell from "@/components/toolkit/ToolShell";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
function num(v: string) {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [speed, setSpeed] = useState("800"); // mm/s
|
||||
const [freq, setFreq] = useState("60"); // kHz
|
||||
const [spotUm, setSpotUm] = useState("50");// µm
|
||||
|
||||
const result = useMemo(() => {
|
||||
const v = num(speed); // mm/s
|
||||
const f = num(freq); // kHz
|
||||
const dUm = num(spotUm); // µm
|
||||
|
||||
if (v <= 0 || f <= 0 || dUm <= 0) {
|
||||
return { spacingUm: 0, spacingMm: 0, overlapPct: 0, pulsesPerMm: 0 };
|
||||
}
|
||||
|
||||
// distance per pulse
|
||||
const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 1000)
|
||||
const spacingMm = spacingUm / 1000;
|
||||
const overlapPct = Math.max(0, Math.min(100, 100 * (1 - spacingUm / dUm)));
|
||||
const pulsesPerMm = (f * 1000) / v;
|
||||
|
||||
return { spacingUm, spacingMm, overlapPct, pulsesPerMm };
|
||||
}, [speed, freq, spotUm]);
|
||||
|
||||
return (
|
||||
<ToolShell title="Pulse Overlap">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Inputs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
|
||||
<Input inputMode="decimal" value={speed} onChange={(e) => setSpeed(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Frequency (kHz)</div>
|
||||
<Input inputMode="decimal" value={freq} onChange={(e) => setFreq(e.target.value)} />
|
||||
</label>
|
||||
<label className="text-[11px] sm:text-xs">
|
||||
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
|
||||
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Pulse spacing</div>
|
||||
<div className="text-lg">{result.spacingMm.toFixed(4)} mm</div>
|
||||
<div className="text-xs text-muted-foreground">{result.spacingUm.toFixed(1)} µm</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Overlap</div>
|
||||
<div className="text-lg">{result.overlapPct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Pulses per mm</div>
|
||||
<div className="text-lg">{result.pulsesPerMm.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Rule of thumb</div>
|
||||
<div className="text-xs">
|
||||
60–80% overlap is common for marking; deeper engraving often higher.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
16
app/portal/buying-guide/page.tsx
Normal file
16
app/portal/buying-guide/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const BuyingGuideSwitcher = dynamic(
|
||||
() => import("@/components/portal/BuyingGuideSwitcher"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function PortalBuyingGuidePage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<BuyingGuideSwitcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue