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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils"; // or roll your own `cn` if you don’t have one
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: "/portal", label: "Home" },
|
{ href: "/portal", label: "Home" },
|
||||||
|
|
@ -12,6 +12,7 @@ const tabs = [
|
||||||
{ href: "/portal/laser-sources", label: "Laser Sources" },
|
{ href: "/portal/laser-sources", label: "Laser Sources" },
|
||||||
{ href: "/portal/materials", label: "Materials" },
|
{ href: "/portal/materials", label: "Materials" },
|
||||||
{ href: "/portal/projects", label: "Projects" },
|
{ href: "/portal/projects", label: "Projects" },
|
||||||
|
{ href: "/portal/buying-guide", label: "Buying Guide" }, // ⬅️ NEW
|
||||||
{ href: "/portal/utilities", label: "Utilities" },
|
{ href: "/portal/utilities", label: "Utilities" },
|
||||||
{ href: "/portal/account", label: "Account" },
|
{ href: "/portal/account", label: "Account" },
|
||||||
];
|
];
|
||||||
|
|
@ -32,9 +33,7 @@ export default function PortalTabs() {
|
||||||
href={t.href}
|
href={t.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 text-sm rounded-md transition",
|
"px-3 py-1.5 text-sm rounded-md transition",
|
||||||
active
|
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
|
|
@ -42,9 +41,7 @@ export default function PortalTabs() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">
|
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">MakerDash</div>
|
||||||
MakerDash
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
190
components/buying-guide/BuyingGuideList.tsx
Normal file
190
components/buying-guide/BuyingGuideList.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { dxGet } from "./dx";
|
||||||
|
|
||||||
|
type Cat = { id: string | number; name: string };
|
||||||
|
type Sub = { id: string | number; name: string; bg_cat_id: string | number };
|
||||||
|
type Entry = {
|
||||||
|
submission_id: string | number;
|
||||||
|
title?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
thumb?: { id: string; filename_disk?: string } | null;
|
||||||
|
bg_cat_id?: { id: string | number; name?: string } | string | number | null;
|
||||||
|
bg_sub_cat_id?: { id: string | number; name?: string } | string | number | null;
|
||||||
|
price_min?: number | null;
|
||||||
|
price_max?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assetUrl(idOrPath?: string) {
|
||||||
|
// Prefer Directus asset URL if you expose one publicly; fallback to /api/dx/assets/:id
|
||||||
|
if (!idOrPath) return "";
|
||||||
|
if (/^https?:\/\//i.test(idOrPath)) return idOrPath;
|
||||||
|
return `/api/dx/assets/${idOrPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuyingGuideList({ embedded = true }: { embedded?: boolean }) {
|
||||||
|
const [cats, setCats] = useState<Cat[]>([]);
|
||||||
|
const [subs, setSubs] = useState<Sub[]>([]);
|
||||||
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [catId, setCatId] = useState<string | number | "all">("all");
|
||||||
|
const [subId, setSubId] = useState<string | number | "all">("all");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [catRes, subRes] = await Promise.all([
|
||||||
|
dxGet<Cat[]>("items/bg_cat", { fields: "id,name", limit: 500, sort: "name" }),
|
||||||
|
dxGet<Sub[]>("items/bg_sub_cat", { fields: "id,name,bg_cat_id", limit: 1000, sort: "name" }),
|
||||||
|
]);
|
||||||
|
setCats(catRes);
|
||||||
|
setSubs(subRes);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Build filter
|
||||||
|
const filter: any = {};
|
||||||
|
if (catId !== "all") filter.bg_cat_id = { _eq: catId };
|
||||||
|
if (subId !== "all") filter.bg_sub_cat_id = { _eq: subId };
|
||||||
|
if (q.trim()) {
|
||||||
|
filter._or = [
|
||||||
|
{ title: { _icontains: q } },
|
||||||
|
{ brand: { _icontains: q } },
|
||||||
|
{ model: { _icontains: q } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await dxGet<Entry[]>("items/bg_entries", {
|
||||||
|
fields: [
|
||||||
|
"submission_id",
|
||||||
|
"title",
|
||||||
|
"brand",
|
||||||
|
"model",
|
||||||
|
"price_min",
|
||||||
|
"price_max",
|
||||||
|
"thumb.id",
|
||||||
|
"bg_cat_id.id",
|
||||||
|
"bg_cat_id.name",
|
||||||
|
"bg_sub_cat_id.id",
|
||||||
|
"bg_sub_cat_id.name",
|
||||||
|
].join(","),
|
||||||
|
filter: JSON.stringify(filter),
|
||||||
|
limit: 48,
|
||||||
|
sort: "brand,model,title",
|
||||||
|
});
|
||||||
|
|
||||||
|
setEntries(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [catId, subId, q]);
|
||||||
|
|
||||||
|
const subsForCat = useMemo(
|
||||||
|
() => (catId === "all" ? subs : subs.filter(s => String(s.bg_cat_id) === String(catId))),
|
||||||
|
[subs, catId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={catId}
|
||||||
|
onChange={e => { setCatId(e.target.value === "all" ? "all" : e.target.value); setSubId("all"); }}
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{cats.map(c => <option key={c.id} value={String(c.id)}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Subcategory</label>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={subId}
|
||||||
|
onChange={e => setSubId(e.target.value === "all" ? "all" : e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{subsForCat.map(s => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[220px]">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1 block">Search</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-1.5"
|
||||||
|
placeholder="brand, model, title…"
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-zinc-400">Loading…</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">No results.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{entries.map(e => {
|
||||||
|
const thumbId = (e.thumb as any)?.id as string | undefined;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={e.submission_id}
|
||||||
|
href={`/buying-guide/${e.submission_id}`}
|
||||||
|
className="rounded-lg border hover:bg-muted/40 p-3 block"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Open product in new tab"
|
||||||
|
>
|
||||||
|
<div className="aspect-[4/3] rounded-md border overflow-hidden mb-2 grid place-items-center bg-muted">
|
||||||
|
{thumbId ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={assetUrl(thumbId)}
|
||||||
|
alt=""
|
||||||
|
className="object-cover w-full h-full"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-zinc-500">No image</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{e.brand ? `${e.brand} ` : ""}{e.model || e.title || "Untitled"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400 truncate">
|
||||||
|
{(e.bg_cat_id as any)?.name || ""}{(e.bg_sub_cat_id as any)?.name ? ` • ${(e.bg_sub_cat_id as any).name}` : ""}
|
||||||
|
</div>
|
||||||
|
{(e.price_min || e.price_max) && (
|
||||||
|
<div className="text-sm mt-1">
|
||||||
|
{e.price_min && e.price_max && e.price_min !== e.price_max
|
||||||
|
? `$${e.price_min.toLocaleString()}–$${e.price_max.toLocaleString()}`
|
||||||
|
: `$${(e.price_min || e.price_max)!.toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
components/buying-guide/BuyingGuideProduct.tsx
Normal file
96
components/buying-guide/BuyingGuideProduct.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dxGet } from "./dx";
|
||||||
|
|
||||||
|
type EntryDetail = {
|
||||||
|
submission_id: string | number;
|
||||||
|
title?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
specs?: any;
|
||||||
|
gallery?: { directus_files_id: { id: string; filename_disk?: string } }[];
|
||||||
|
thumb?: { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function assetUrl(id?: string) {
|
||||||
|
if (!id) return "";
|
||||||
|
return `/api/dx/assets/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuyingGuideProduct({ id }: { id: string | number }) {
|
||||||
|
const [rec, setRec] = useState<EntryDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await dxGet<EntryDetail[]>("items/bg_entries", {
|
||||||
|
filter: JSON.stringify({ submission_id: { _eq: id } }),
|
||||||
|
fields: [
|
||||||
|
"submission_id",
|
||||||
|
"title",
|
||||||
|
"brand",
|
||||||
|
"model",
|
||||||
|
"body",
|
||||||
|
"specs",
|
||||||
|
"thumb.id",
|
||||||
|
"gallery.directus_files_id.id",
|
||||||
|
].join(","),
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
setRec(data?.[0] || null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="text-sm text-zinc-400">Loading…</div>;
|
||||||
|
if (!rec) return <div className="text-sm text-zinc-400">Not found.</div>;
|
||||||
|
|
||||||
|
const title = rec.model || rec.title || "Product";
|
||||||
|
const images = [
|
||||||
|
rec.thumb?.id,
|
||||||
|
...(rec.gallery?.map(g => g.directus_files_id?.id).filter(Boolean) as string[]),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{rec.brand ? `${rec.brand} ` : ""}{title}
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href={`/buying-guide/${rec.submission_id}`}
|
||||||
|
className="text-sm underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open full page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{images.map((id) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img key={id} src={assetUrl(id)} alt="" className="rounded-md border object-cover w-full aspect-[4/3]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rec.body && (
|
||||||
|
<div className="prose prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: rec.body }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rec.specs && (
|
||||||
|
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto text-xs">
|
||||||
|
{JSON.stringify(rec.specs, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
components/buying-guide/LaserFinderPanel.tsx
Normal file
171
components/buying-guide/LaserFinderPanel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { dxGet } from "./dx";
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
submission_id: string | number;
|
||||||
|
brand?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
price_min?: number | null;
|
||||||
|
price_max?: number | null;
|
||||||
|
laser_type?: string | null; // e.g., "CO2", "Diode", "Fiber" if present in your schema
|
||||||
|
bed_size_x?: number | null;
|
||||||
|
bed_size_y?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LaserFinderPanel() {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [type, setType] = useState<string | "any">("any");
|
||||||
|
const [budget, setBudget] = useState<number | null>(null);
|
||||||
|
const [minBedX, setMinBedX] = useState<number | null>(null);
|
||||||
|
const [minBedY, setMinBedY] = useState<number | null>(null);
|
||||||
|
const [recs, setRecs] = useState<Entry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const filter = useMemo(() => {
|
||||||
|
const f: any = {};
|
||||||
|
const ors: any[] = [];
|
||||||
|
if (q.trim()) {
|
||||||
|
ors.push({ brand: { _icontains: q } }, { model: { _icontains: q } }, { title: { _icontains: q } });
|
||||||
|
}
|
||||||
|
if (type !== "any") f.laser_type = { _eq: type };
|
||||||
|
if (budget != null) {
|
||||||
|
// either min/max under budget or range overlaps
|
||||||
|
ors.push(
|
||||||
|
{ price_min: { _lte: budget } },
|
||||||
|
{ price_max: { _lte: budget } },
|
||||||
|
{ _and: [{ price_min: { _lte: budget } }, { price_max: { _gte: 1 } }] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (minBedX != null) f.bed_size_x = { _gte: minBedX };
|
||||||
|
if (minBedY != null) f.bed_size_y = { _gte: minBedY };
|
||||||
|
if (ors.length) f._or = ors;
|
||||||
|
return f;
|
||||||
|
}, [q, type, budget, minBedX, minBedY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await dxGet<Entry[]>("items/bg_entries", {
|
||||||
|
fields: "submission_id,brand,model,title,price_min,price_max,laser_type,bed_size_x,bed_size_y",
|
||||||
|
filter: JSON.stringify(filter),
|
||||||
|
limit: 50,
|
||||||
|
sort: "price_min,brand,model",
|
||||||
|
});
|
||||||
|
setRecs(data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-3 items-end">
|
||||||
|
<div className="flex-1 min-w-[220px]">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1 block">Search</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-1.5"
|
||||||
|
placeholder="brand, model, title…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="any">Any</option>
|
||||||
|
<option value="CO2">CO2</option>
|
||||||
|
<option value="Diode">Diode</option>
|
||||||
|
<option value="Fiber">Fiber</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-[140px]">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Budget (USD)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={budget ?? ""}
|
||||||
|
onChange={(e) => setBudget(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-[120px]">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Min Bed X (mm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={minBedX ?? ""}
|
||||||
|
onChange={(e) => setMinBedX(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-[120px]">
|
||||||
|
<label className="text-xs text-zinc-400 mb-1">Min Bed Y (mm)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="rounded-md border bg-background px-2 py-1"
|
||||||
|
value={minBedY ?? ""}
|
||||||
|
onChange={(e) => setMinBedY(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-zinc-400">Searching…</div>
|
||||||
|
) : recs.length === 0 ? (
|
||||||
|
<div className="text-sm text-zinc-400">No matching lasers.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3 font-normal">Laser</th>
|
||||||
|
<th className="py-1 pr-3 font-normal">Type</th>
|
||||||
|
<th className="py-1 pr-3 font-normal">Bed (mm)</th>
|
||||||
|
<th className="py-1 pr-3 font-normal">Price</th>
|
||||||
|
<th className="py-1 pr-3 font-normal"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recs.map(r => (
|
||||||
|
<tr key={r.submission_id} className="border-t">
|
||||||
|
<td className="py-1 pr-3">{r.brand} {r.model || r.title}</td>
|
||||||
|
<td className="py-1 pr-3">{r.laser_type || "—"}</td>
|
||||||
|
<td className="py-1 pr-3">
|
||||||
|
{(r.bed_size_x ?? "—")} × {(r.bed_size_y ?? "—")}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3">
|
||||||
|
{(r.price_min || r.price_max)
|
||||||
|
? (r.price_min && r.price_max && r.price_min !== r.price_max
|
||||||
|
? `$${r.price_min.toLocaleString()}–$${r.price_max.toLocaleString()}`
|
||||||
|
: `$${(r.price_min || r.price_max)!.toLocaleString()}`)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3">
|
||||||
|
<a
|
||||||
|
className="underline"
|
||||||
|
href={`/buying-guide/${r.submission_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
components/buying-guide/dx.ts
Normal file
12
components/buying-guide/dx.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// components/utilities/buying-guide/dx.ts
|
||||||
|
export type Q = Record<string, any>;
|
||||||
|
|
||||||
|
export async function dxGet<T>(path: string, query?: Q): Promise<T> {
|
||||||
|
const qs = query ? "?" + new URLSearchParams(Object.entries(query).flatMap(([k, v]) =>
|
||||||
|
Array.isArray(v) ? v.map(x => [k, String(x)]) : [[k, String(v)]]
|
||||||
|
)).toString() : "";
|
||||||
|
const res = await fetch(`/api/dx/${path}${qs}`, { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||||
|
const json = await res.json();
|
||||||
|
return json?.data ?? json;
|
||||||
|
}
|
||||||
60
components/portal/BuyingGuideSwitcher.tsx
Normal file
60
components/portal/BuyingGuideSwitcher.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// these should already exist under components/buying-guide/*
|
||||||
|
const BuyingGuideList = dynamic(
|
||||||
|
() => import("@/components/buying-guide/BuyingGuideList"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const LaserFinderPanel = dynamic(
|
||||||
|
() => import("@/components/buying-guide/LaserFinderPanel"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "list", label: "Guide" },
|
||||||
|
{ key: "finder", label: "Laser Finder" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function BuyingGuideSwitcher() {
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const active = useMemo(() => {
|
||||||
|
const t = (sp.get("bg") || TABS[0].key).toLowerCase();
|
||||||
|
return TABS.some(x => x.key === t) ? t : TABS[0].key;
|
||||||
|
}, [sp]);
|
||||||
|
|
||||||
|
function setTab(k: string) {
|
||||||
|
const q = new URLSearchParams(sp.toString());
|
||||||
|
q.set("bg", k);
|
||||||
|
router.replace(`/portal/buying-guide?${q.toString()}`, { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-1.5 text-sm",
|
||||||
|
active === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
{active === "finder" ? <LaserFinderPanel /> : <BuyingGuideList />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/portal/LaserToolkitSwitcher.tsx
Normal file
58
components/portal/LaserToolkitSwitcher.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// components/portal/LaserToolkitSwitcher.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TOOLKIT_TABS } from "@/components/utilities/laser-toolkit/registry";
|
||||||
|
|
||||||
|
export default function LaserToolkitSwitcher() {
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const activeKey = useMemo(() => {
|
||||||
|
const def = TOOLKIT_TABS[0]?.key ?? "beam-spot-size";
|
||||||
|
const t = (sp.get("lt") || def).toLowerCase();
|
||||||
|
return TOOLKIT_TABS.some(x => x.key === t) ? t : def;
|
||||||
|
}, [sp]);
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => TOOLKIT_TABS.find(x => x.key === activeKey) ?? TOOLKIT_TABS[0],
|
||||||
|
[activeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
function setTab(k: string) {
|
||||||
|
const q = new URLSearchParams(sp.toString());
|
||||||
|
q.set("lt", k);
|
||||||
|
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return <div className="text-sm text-zinc-400">No tools registered.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveCmp = active.component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TOOLKIT_TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border px-3 py-1.5 text-sm",
|
||||||
|
activeKey === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<ActiveCmp />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -10,44 +11,70 @@ type Item = {
|
||||||
label: string;
|
label: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
icon?: string; // optional icon (public/images/utils/<icon>)
|
icon?: string; // optional icon (public/images/utils/<icon>)
|
||||||
href: string; // absolute URL
|
href?: string; // optional absolute URL (used if no component)
|
||||||
|
component?: React.ComponentType<{ embedded?: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Lazy-load heavy utilities
|
||||||
|
const BackgroundRemoverPanel = dynamic(
|
||||||
|
() => import("@/components/utilities/BackgroundRemoverPanel"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const SVGNestPanel = dynamic(
|
||||||
|
() => import("@/components/utilities/SVGNestPanel"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const LaserToolkitSwitcher = dynamic(
|
||||||
|
() => import("@/components/portal/LaserToolkitSwitcher"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
// Inline File Server
|
||||||
|
const FileBrowserPanel = dynamic(
|
||||||
|
() => import("@/components/utilities/files/FileBrowserPanel"),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
const ITEMS: Item[] = [
|
const ITEMS: Item[] = [
|
||||||
// On-site (embed)
|
// ✅ Laser Toolkit now renders inline with sub-tabs
|
||||||
{
|
{
|
||||||
key: "laser-toolkit",
|
key: "laser-toolkit",
|
||||||
label: "Laser Toolkit",
|
label: "Laser Toolkit",
|
||||||
note: "convert laser settings, interval and more",
|
note: "convert laser settings, interval and more",
|
||||||
icon: "toolkit.png",
|
icon: "toolkit.png",
|
||||||
href: "https://makearmy.io/laser-toolkit",
|
component: LaserToolkitSwitcher,
|
||||||
|
href: "https://makearmy.io/laser-toolkit", // optional; component takes precedence
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ✅ File Server inline (no iframe)
|
||||||
{
|
{
|
||||||
key: "files",
|
key: "files",
|
||||||
label: "File Server",
|
label: "File Server",
|
||||||
note: "download from our file explorer",
|
note: "download from our file explorer",
|
||||||
icon: "fs.png",
|
icon: "fs.png",
|
||||||
|
component: FileBrowserPanel,
|
||||||
href: "https://makearmy.io/files",
|
href: "https://makearmy.io/files",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "buying-guide",
|
// Buying Guide moved to main portal tab — remove here to avoid duplication
|
||||||
label: "Buying Guide",
|
// { key: "buying-guide", ... }
|
||||||
note: "reviews and listings for relevant products",
|
|
||||||
icon: "bg.png",
|
// ✅ SVGnest inline (micro-frontend wrapper)
|
||||||
href: "https://makearmy.io/buying-guide",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "svgnest",
|
key: "svgnest",
|
||||||
label: "SVGnest",
|
label: "SVGnest",
|
||||||
note: "automatically nests parts and exports svg",
|
note: "automatically nests parts and exports svg",
|
||||||
icon: "nest.png",
|
icon: "nest.png",
|
||||||
|
component: SVGNestPanel,
|
||||||
href: "https://makearmy.io/svgnest",
|
href: "https://makearmy.io/svgnest",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ✅ Background Remover inline
|
||||||
{
|
{
|
||||||
key: "background-remover",
|
key: "background-remover",
|
||||||
label: "BG Remover",
|
label: "BG Remover",
|
||||||
note: "open source background remover",
|
note: "open source background remover",
|
||||||
icon: "bgrm.png",
|
icon: "bgrm.png",
|
||||||
|
component: BackgroundRemoverPanel,
|
||||||
href: "https://makearmy.io/background-remover",
|
href: "https://makearmy.io/background-remover",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -75,7 +102,8 @@ const ITEMS: Item[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function isExternal(urlStr: string) {
|
function isExternal(urlStr: string | undefined) {
|
||||||
|
if (!urlStr) return false;
|
||||||
try {
|
try {
|
||||||
const u = new URL(urlStr);
|
const u = new URL(urlStr);
|
||||||
return u.hostname !== "makearmy.io";
|
return u.hostname !== "makearmy.io";
|
||||||
|
|
@ -96,8 +124,17 @@ function toOnsitePath(urlStr: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel({ item }: { item: Item }) {
|
function Panel({ item }: { item: Item }) {
|
||||||
const external = isExternal(item.href);
|
if (item.component) {
|
||||||
|
const Cmp = item.component;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{item.note ? <div className="text-sm opacity-70">{item.note}</div> : null}
|
||||||
|
<Cmp embedded />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const external = isExternal(item.href);
|
||||||
if (external) {
|
if (external) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
|
|
@ -116,10 +153,10 @@ function Panel({ item }: { item: Item }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = toOnsitePath(item.href);
|
const src = toOnsitePath(item.href || "/");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm opacity-70">{item.note}</div>
|
{item.note ? <div className="text-sm opacity-70">{item.note}</div> : null}
|
||||||
<iframe
|
<iframe
|
||||||
key={src}
|
key={src}
|
||||||
src={src}
|
src={src}
|
||||||
|
|
@ -133,7 +170,7 @@ function Panel({ item }: { item: Item }) {
|
||||||
export default function UtilitySwitcher() {
|
export default function UtilitySwitcher() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
const openedRef = useRef<string | null>(null); // prevent double window.open
|
const openedRef = useRef<string | null>(null);
|
||||||
const [firstPaint, setFirstPaint] = useState(true);
|
const [firstPaint, setFirstPaint] = useState(true);
|
||||||
|
|
||||||
const activeKey = useMemo(() => {
|
const activeKey = useMemo(() => {
|
||||||
|
|
@ -152,23 +189,19 @@ export default function UtilitySwitcher() {
|
||||||
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// When landing on an external tab, open it once in a new tab.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const item = activeItem;
|
const item = activeItem;
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
if (item.component) return;
|
||||||
const external = isExternal(item.href);
|
const external = isExternal(item.href);
|
||||||
if (!external) return;
|
if (!external) return;
|
||||||
|
|
||||||
// Avoid duplicate opens in strict mode / re-renders
|
|
||||||
if (openedRef.current === item.key) return;
|
if (openedRef.current === item.key) return;
|
||||||
openedRef.current = item.key;
|
openedRef.current = item.key;
|
||||||
|
|
||||||
// Don’t auto-open on the very first paint if you prefer manual click only.
|
|
||||||
// Set to false to always auto-open, even on initial load of ?t=<external>.
|
|
||||||
const AUTO_OPEN_ON_FIRST_PAINT = true;
|
const AUTO_OPEN_ON_FIRST_PAINT = true;
|
||||||
|
|
||||||
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
||||||
window.open(item.href, "_blank", "noopener,noreferrer");
|
window.open(item.href!, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeItem?.key, activeItem?.href]);
|
}, [activeItem?.key, activeItem?.href]);
|
||||||
|
|
@ -181,7 +214,8 @@ export default function UtilitySwitcher() {
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
{ITEMS.map((it) => {
|
{ITEMS.map((it) => {
|
||||||
const external = isExternal(it.href);
|
const isInline = Boolean(it.component);
|
||||||
|
const external = !isInline && isExternal(it.href);
|
||||||
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
||||||
const isActive = it.key === activeKey;
|
const isActive = it.key === activeKey;
|
||||||
return (
|
return (
|
||||||
|
|
@ -189,9 +223,8 @@ export default function UtilitySwitcher() {
|
||||||
key={it.key}
|
key={it.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTab(it.key);
|
setTab(it.key);
|
||||||
if (external) {
|
if (!isInline && external) {
|
||||||
// Also open immediately on click
|
window.open(it.href!, "_blank", "noopener,noreferrer");
|
||||||
window.open(it.href, "_blank", "noopener,noreferrer");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -214,7 +247,7 @@ export default function UtilitySwitcher() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{it.label}</span>
|
<span className="truncate">{it.label}</span>
|
||||||
{external && (
|
{!isInline && external && (
|
||||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
new tab
|
new tab
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
528
components/utilities/BackgroundRemoverPanel.tsx
Normal file
528
components/utilities/BackgroundRemoverPanel.tsx
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
// components/utilities/BackgroundRemoverPanel.tsx
|
||||||
|
"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 BackgroundRemoverPanel({ embedded = true }: { embedded?: boolean }) {
|
||||||
|
// ---------- 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-0 text-zinc-100 overflow-x-hidden">
|
||||||
|
{styles}
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-[1200px] px-0">
|
||||||
|
{/* 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 */}
|
||||||
|
<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 */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
components/utilities/SVGNestPanel.tsx
Normal file
149
components/utilities/SVGNestPanel.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline loader for a static app (SVGnest) placed under /public/svgnest.
|
||||||
|
* It fetches index.html, injects the HTML into a container, then
|
||||||
|
* re-executes <script> tags (both external and inline) in order.
|
||||||
|
* CSS <link> files are fetched and inlined into <style> tags to avoid path issues.
|
||||||
|
*
|
||||||
|
* NOTE: This runs in the light DOM (not shadow). That maximizes compatibility
|
||||||
|
* with older scripts that rely on document-level selectors. If we see style bleed,
|
||||||
|
* we can optionally switch to a Shadow DOM variant later.
|
||||||
|
*/
|
||||||
|
export default function SVGNestPanel() {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// 1) Fetch index.html
|
||||||
|
const htmlRes = await fetch("/svgnest/index.html", { cache: "no-cache" });
|
||||||
|
if (!htmlRes.ok) throw new Error(`Failed to fetch index.html (HTTP ${htmlRes.status})`);
|
||||||
|
const htmlText = await htmlRes.text();
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// 2) Parse
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(htmlText, "text/html");
|
||||||
|
|
||||||
|
// 3) Find and inline CSS (convert <link rel="stylesheet"> to <style>)
|
||||||
|
const linkEls = Array.from(doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"][href]'));
|
||||||
|
for (const link of linkEls) {
|
||||||
|
const href = link.getAttribute("href") || "";
|
||||||
|
const absHref = absolutizeAsset(href);
|
||||||
|
try {
|
||||||
|
const cssRes = await fetch(absHref, { cache: "no-cache" });
|
||||||
|
if (cssRes.ok) {
|
||||||
|
const cssText = await cssRes.text();
|
||||||
|
const style = doc.createElement("style");
|
||||||
|
style.setAttribute("data-inlined-from", href);
|
||||||
|
style.textContent = cssText;
|
||||||
|
link.replaceWith(style);
|
||||||
|
} else {
|
||||||
|
// leave the original link as a fallback
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave the original link as a fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Extract scripts (to re-execute later) & remove them from doc body
|
||||||
|
const scriptInfos: { src?: string; inline?: string; type?: string; nomodule?: boolean; async?: boolean; defer?: boolean }[] = [];
|
||||||
|
doc.querySelectorAll("script").forEach((s) => {
|
||||||
|
scriptInfos.push({
|
||||||
|
src: s.getAttribute("src") || undefined,
|
||||||
|
inline: s.textContent || undefined,
|
||||||
|
type: s.getAttribute("type") || undefined,
|
||||||
|
nomodule: s.hasAttribute("nomodule"),
|
||||||
|
async: s.hasAttribute("async"),
|
||||||
|
defer: s.hasAttribute("defer"),
|
||||||
|
});
|
||||||
|
s.remove(); // prevent duplicate execution when we inject innerHTML
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Inject BODY HTML into our container
|
||||||
|
const host = containerRef.current!;
|
||||||
|
host.innerHTML = doc.body.innerHTML;
|
||||||
|
|
||||||
|
// 6) Sequentially (re)execute scripts in original order
|
||||||
|
// We do this after body is in place so DOM targets exist.
|
||||||
|
for (const info of scriptInfos) {
|
||||||
|
await runScript(host, info);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || String(e));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 rounded border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
|
||||||
|
Couldn’t load SVGnest: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-3 text-sm text-zinc-400">Loading SVGnest…</div>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="w-full min-h-[70vh]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a (possibly relative) asset path from /svgnest/index.html into an absolute path */
|
||||||
|
function absolutizeAsset(path: string): string {
|
||||||
|
// Ignore full URLs
|
||||||
|
try {
|
||||||
|
const u = new URL(path);
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
// not a full URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle protocol-relative //host/path -> treat as absolute
|
||||||
|
if (path.startsWith("//")) return window.location.protocol + path;
|
||||||
|
|
||||||
|
// If already absolute (/x/y), leave as-is
|
||||||
|
if (path.startsWith("/")) return path;
|
||||||
|
|
||||||
|
// Otherwise treat as relative to /svgnest/
|
||||||
|
return `/svgnest/${path.replace(/^\.?\//, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a new <script> to the container, respecting order. Supports src and inline. */
|
||||||
|
function runScript(container: HTMLElement, info: { src?: string; inline?: string; type?: string; nomodule?: boolean; async?: boolean; defer?: boolean; }): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement("script");
|
||||||
|
if (info.type) s.type = info.type;
|
||||||
|
if (info.nomodule) s.noModule = true;
|
||||||
|
// Preserve async/defer semantics if they existed
|
||||||
|
if (info.async) s.async = true;
|
||||||
|
if (info.defer) s.defer = true;
|
||||||
|
|
||||||
|
if (info.src) {
|
||||||
|
s.src = absolutizeAsset(info.src);
|
||||||
|
s.onload = () => resolve();
|
||||||
|
s.onerror = () => reject(new Error(`Failed to load script: ${s.src}`));
|
||||||
|
container.appendChild(s);
|
||||||
|
} else {
|
||||||
|
// Inline script: execute synchronously
|
||||||
|
s.textContent = info.inline || "";
|
||||||
|
container.appendChild(s);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
166
components/utilities/files/FileBrowserPanel.tsx
Normal file
166
components/utilities/files/FileBrowserPanel.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// components/utilities/files/FileBrowserPanel.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronRight, Home, Loader2 } from "lucide-react";
|
||||||
|
import FilesTable from "./FilesTable";
|
||||||
|
import FilePreview from "./FilePreview";
|
||||||
|
import { FsEntry, list, parentDir, download, SortDir, SortKey } from "./api";
|
||||||
|
|
||||||
|
export default function FileBrowserPanel() {
|
||||||
|
const [cwd, setCwd] = useState<string>("/");
|
||||||
|
const [entries, setEntries] = useState<FsEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<FsEntry | null>(null);
|
||||||
|
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
|
||||||
|
const refresh = useCallback(async (path: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await list(path);
|
||||||
|
setCwd(res.cwd || path || "/");
|
||||||
|
setEntries(res.entries || []);
|
||||||
|
// Clear selection if it no longer exists
|
||||||
|
if (selected && !res.entries.find(e => e.path === selected.path)) setSelected(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(cwd); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []);
|
||||||
|
|
||||||
|
const crumbs = useMemo(() => {
|
||||||
|
const norm = cwd.replace(/\\/g, "/");
|
||||||
|
const segs = norm.split("/").filter(Boolean);
|
||||||
|
const out: { label: string; path: string }[] = [{ label: "root", path: "/" }];
|
||||||
|
let acc = "";
|
||||||
|
for (const s of segs) {
|
||||||
|
acc += "/" + s;
|
||||||
|
out.push({ label: s, path: acc || "/" });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [cwd]);
|
||||||
|
|
||||||
|
function openEntry(e: FsEntry) {
|
||||||
|
if (e.isDir) {
|
||||||
|
setSelected(null);
|
||||||
|
refresh(e.path);
|
||||||
|
} else {
|
||||||
|
setSelected(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSort(k: SortKey) {
|
||||||
|
if (k === sortKey) {
|
||||||
|
setSortDir(d => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortKey(k);
|
||||||
|
setSortDir("asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goUp() {
|
||||||
|
const p = parentDir(cwd);
|
||||||
|
setSelected(null);
|
||||||
|
await refresh(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<nav className="flex items-center gap-1 text-sm">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={c.path} className="inline-flex items-center">
|
||||||
|
{i === 0 ? (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 hover:bg-muted"
|
||||||
|
onClick={() => { setSelected(null); refresh("/"); }}
|
||||||
|
title="Go to root"
|
||||||
|
>
|
||||||
|
<Home className="w-3.5 h-3.5" />
|
||||||
|
root
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-md border px-2 py-0.5 hover:bg-muted"
|
||||||
|
onClick={() => { setSelected(null); refresh(c.path); }}
|
||||||
|
title={c.path}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{i < crumbs.length - 1 && <ChevronRight className="w-3.5 h-3.5 mx-1 opacity-60" />}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refresh(cwd)}
|
||||||
|
className="rounded-md border px-2 py-1 text-sm hover:bg-muted inline-flex items-center gap-2"
|
||||||
|
title="Refresh"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goUp}
|
||||||
|
className="rounded-md border px-2 py-1 text-sm hover:bg-muted"
|
||||||
|
title="Up one level"
|
||||||
|
disabled={cwd === "/"}
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-md border p-6 text-sm text-zinc-400 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FilesTable
|
||||||
|
entries={entries}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDir={sortDir}
|
||||||
|
onSort={onSort}
|
||||||
|
onOpen={openEntry}
|
||||||
|
onDownload={(e) => download(e.path)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-sm text-zinc-400 mb-2">Preview</div>
|
||||||
|
{selected ? (
|
||||||
|
<FilePreview path={selected.path} mime={selected.mime} name={selected.name} />
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-zinc-500">Select a file to preview.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/utilities/files/FilePreview.tsx
Normal file
56
components/utilities/files/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// components/utilities/files/FilePreview.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { rawUrl, isPreviewableImage, isPreviewableText, isPreviewablePdf } from "./api";
|
||||||
|
|
||||||
|
export default function FilePreview({ path, mime, name }: { path: string; mime?: string | null; name?: string }) {
|
||||||
|
const [text, setText] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setText("");
|
||||||
|
if (isPreviewableText(mime, name)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(rawUrl(path), { cache: "no-store" });
|
||||||
|
const t = await res.text();
|
||||||
|
if (!cancelled) setText(t.slice(0, 100_000)); // safety cap
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setText("Unable to load text preview.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [path, mime, name]);
|
||||||
|
|
||||||
|
if (isPreviewableImage(mime, name)) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={rawUrl(path)} alt={name || ""} className="w-full max-h-[60vh] object-contain bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreviewablePdf(mime, name)) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={rawUrl(path)}
|
||||||
|
className="w-full h-[60vh] rounded-md border"
|
||||||
|
title={name || "PDF preview"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPreviewableText(mime, name)) {
|
||||||
|
return (
|
||||||
|
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto max-h-[60vh] text-xs whitespace-pre-wrap">
|
||||||
|
{text || "Loading…"}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="text-sm text-zinc-400">No preview available.</div>;
|
||||||
|
}
|
||||||
112
components/utilities/files/FilesTable.tsx
Normal file
112
components/utilities/files/FilesTable.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// components/utilities/files/FilesTable.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ArrowDownAZ, ArrowUpAZ, Download, Folder, FileText } from "lucide-react";
|
||||||
|
import { FsEntry, SortKey, SortDir, nicelyFormatBytes } from "./api";
|
||||||
|
|
||||||
|
export default function FilesTable({
|
||||||
|
entries,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
onOpen,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
entries: FsEntry[];
|
||||||
|
sortKey: SortKey;
|
||||||
|
sortDir: SortDir;
|
||||||
|
onSort: (k: SortKey) => void;
|
||||||
|
onOpen: (entry: FsEntry) => void;
|
||||||
|
onDownload: (entry: FsEntry) => void;
|
||||||
|
}) {
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const arr = [...entries];
|
||||||
|
const dir = sortDir === "asc" ? 1 : -1;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
// folders first
|
||||||
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||||
|
|
||||||
|
switch (sortKey) {
|
||||||
|
case "name": return a.name.localeCompare(b.name) * dir;
|
||||||
|
case "size": return ((a.size ?? -1) - (b.size ?? -1)) * dir;
|
||||||
|
case "modified": return (new Date(a.modified || 0).getTime() - new Date(b.modified || 0).getTime()) * dir;
|
||||||
|
case "type": {
|
||||||
|
const ax = ext(a.name), bx = ext(b.name);
|
||||||
|
return ax.localeCompare(bx) * dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}, [entries, sortKey, sortDir]);
|
||||||
|
|
||||||
|
function ext(name: string) {
|
||||||
|
const m = /\.([^.]+)$/.exec(name || "");
|
||||||
|
return m ? m[1].toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortBtn({ k, label }: { k: SortKey; label: string }) {
|
||||||
|
const active = sortKey === k;
|
||||||
|
return (
|
||||||
|
<button className="inline-flex items-center gap-1 text-left" onClick={() => onSort(k)}>
|
||||||
|
{label}
|
||||||
|
{active ? (
|
||||||
|
sortDir === "asc" ? <ArrowUpAZ className="w-3.5 h-3.5 opacity-60" /> : <ArrowDownAZ className="w-3.5 h-3.5 opacity-60" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-zinc-400 bg-muted/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 w-[40%]"><SortBtn k="name" label="Name" /></th>
|
||||||
|
<th className="px-3 py-2 w-[15%]"><SortBtn k="type" label="Type" /></th>
|
||||||
|
<th className="px-3 py-2 w-[15%]"><SortBtn k="size" label="Size" /></th>
|
||||||
|
<th className="px-3 py-2 w-[30%]"><SortBtn k="modified" label="Modified" /></th>
|
||||||
|
<th className="px-3 py-2 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((e) => (
|
||||||
|
<tr key={e.path} className="border-t hover:bg-muted/30">
|
||||||
|
<td
|
||||||
|
className="px-3 py-2 cursor-pointer"
|
||||||
|
onDoubleClick={() => onOpen(e)}
|
||||||
|
title="Double-click to open"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{e.isDir ? <Folder className="w-4 h-4 opacity-70" /> : <FileText className="w-4 h-4 opacity-70" />}
|
||||||
|
<span className="truncate">{e.name}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{e.isDir ? "Folder" : (e.mime || ext(e.name).toUpperCase() || "File")}</td>
|
||||||
|
<td className="px-3 py-2">{e.isDir ? "—" : nicelyFormatBytes(e.size)}</td>
|
||||||
|
<td className="px-3 py-2">{e.modified ? new Date(e.modified).toLocaleString() : "—"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{!e.isDir && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
className="rounded-md border px-2 py-1 text-xs hover:bg-muted inline-flex items-center gap-1"
|
||||||
|
onClick={() => onDownload(e)}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-sm text-zinc-500" colSpan={5}>Empty folder.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/utilities/files/api.ts
Normal file
81
components/utilities/files/api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// components/utilities/files/api.ts
|
||||||
|
export type FsEntry = {
|
||||||
|
name: string;
|
||||||
|
path: string; // absolute or from root, e.g. "/public" or "/public/readme.txt"
|
||||||
|
isDir: boolean;
|
||||||
|
size?: number | null; // bytes
|
||||||
|
modified?: string | null; // ISO date string
|
||||||
|
mime?: string | null; // server-provided mime, optional
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListResponse = {
|
||||||
|
cwd: string; // normalized path we listed
|
||||||
|
entries: FsEntry[]; // unsorted list
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortKey = "name" | "size" | "modified" | "type";
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export async function list(path: string): Promise<ListResponse> {
|
||||||
|
const u = new URL("/api/files/list", location.origin);
|
||||||
|
if (path) u.searchParams.set("path", path);
|
||||||
|
const res = await fetch(u.toString(), { credentials: "include", cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`list ${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rawUrl(path: string): string {
|
||||||
|
const u = new URL("/api/files/raw", location.origin);
|
||||||
|
u.searchParams.set("path", path);
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download(path: string): Promise<void> {
|
||||||
|
// try direct browser download via a hidden <a download>
|
||||||
|
const u = new URL("/api/files/download", location.origin);
|
||||||
|
u.searchParams.set("path", path);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = u.toString();
|
||||||
|
a.rel = "noopener";
|
||||||
|
a.download = ""; // hint to save-as
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parentDir(p: string): string {
|
||||||
|
if (!p || p === "/") return "/";
|
||||||
|
const segs = p.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||||
|
segs.pop();
|
||||||
|
return "/" + segs.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nicelyFormatBytes(n?: number | null): string {
|
||||||
|
if (!Number.isFinite(n as number) || (n as number) < 0) return "—";
|
||||||
|
const b = n as number;
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
const units = ["KB","MB","GB","TB"];
|
||||||
|
let v = b / 1024, i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extFromName(name: string): string {
|
||||||
|
const m = /\.([^.]+)$/.exec(name || "");
|
||||||
|
return m ? m[1].toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewableImage(mime?: string | null, name?: string): boolean {
|
||||||
|
const ext = extFromName(name || "");
|
||||||
|
return /^image\//.test(mime || "") || ["png","jpg","jpeg","gif","webp","bmp","svg"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewableText(mime?: string | null, name?: string): boolean {
|
||||||
|
const ext = extFromName(name || "");
|
||||||
|
return /^text\//.test(mime || "") || ["txt","csv","md","json","log"].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewablePdf(mime?: string | null, name?: string): boolean {
|
||||||
|
const ext = extFromName(name || "");
|
||||||
|
return (mime || "").includes("pdf") || ext === "pdf";
|
||||||
|
}
|
||||||
52
components/utilities/laser-toolkit/registry.ts
Normal file
52
components/utilities/laser-toolkit/registry.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// components/utilities/laser-toolkit/registry.ts
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export type ToolkitTab = {
|
||||||
|
key: string; // used in ?lt=<key>
|
||||||
|
label: string; // tab label
|
||||||
|
component: React.ComponentType<{}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points directly at your existing files:
|
||||||
|
* - beam-spot-size/page.tsx
|
||||||
|
* - dpi-lpi-dpcm/page.tsx
|
||||||
|
* - hatch-overlap/page.tsx
|
||||||
|
* - job-time-estimator/page.tsx
|
||||||
|
* - power-lens-scaler/page.tsx
|
||||||
|
* - pulse-overlap/page.tsx
|
||||||
|
*/
|
||||||
|
export const TOOLKIT_TABS: ToolkitTab[] = [
|
||||||
|
{
|
||||||
|
key: "beam-spot-size",
|
||||||
|
label: "Beam Spot Size",
|
||||||
|
component: dynamic(() => import("./beam-spot-size/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dpi-lpi-dpcm",
|
||||||
|
label: "DPI / LPI / DPCM",
|
||||||
|
component: dynamic(() => import("./dpi-lpi-dpcm/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hatch-overlap",
|
||||||
|
label: "Hatch Overlap",
|
||||||
|
component: dynamic(() => import("./hatch-overlap/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "job-time-estimator",
|
||||||
|
label: "Job Time Estimator",
|
||||||
|
component: dynamic(() => import("./job-time-estimator/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "power-lens-scaler",
|
||||||
|
label: "Power / Lens Scaler",
|
||||||
|
component: dynamic(() => import("./power-lens-scaler/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pulse-overlap",
|
||||||
|
label: "Pulse Overlap",
|
||||||
|
component: dynamic(() => import("./pulse-overlap/page"), { ssr: false }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -23,6 +23,8 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
/** Directus base (used to remotely validate the token after restarts). */
|
/** Directus base (used to remotely validate the token after restarts). */
|
||||||
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||||
|
|
||||||
|
type MapResult = { pathname: string; query?: Record<string, string> };
|
||||||
|
|
||||||
/** Helper: does the path start with any prefix in a list? */
|
/** Helper: does the path start with any prefix in a list? */
|
||||||
function startsWithAny(pathname: string, prefixes: string[]) {
|
function startsWithAny(pathname: string, prefixes: string[]) {
|
||||||
return prefixes.some((p) => pathname.startsWith(p));
|
return prefixes.some((p) => pathname.startsWith(p));
|
||||||
|
|
@ -176,8 +178,6 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
type MapResult = { pathname: string; query?: Record<string, string> };
|
|
||||||
|
|
||||||
function legacyMap(pathname: string): MapResult | null {
|
function legacyMap(pathname: string): MapResult | null {
|
||||||
// Never map the homepage, and if we’re already inside the portal, don’t remap again.
|
// Never map the homepage, and if we’re already inside the portal, don’t remap again.
|
||||||
if (pathname === "/" || pathname.startsWith("/portal")) return null;
|
if (pathname === "/" || pathname.startsWith("/portal")) return null;
|
||||||
|
|
@ -192,6 +192,8 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
[/^\/co2-galvo-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-galvo", id: m[1] } })],
|
[/^\/co2-galvo-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-galvo", id: m[1] } })],
|
||||||
[/^\/co2-gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
|
[/^\/co2-gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
|
||||||
[/^\/co2gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
|
[/^\/co2gantry-settings\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/laser-settings", query: { t: "co2-gantry", id: m[1] } })],
|
||||||
|
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
|
||||||
|
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
|
||||||
|
|
||||||
// Materials
|
// Materials
|
||||||
[/^\/materials\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials", id: m[1] } })],
|
[/^\/materials\/([^/]+)\/?$/i, (m) => ({ pathname: "/portal/materials", query: { t: "materials", id: m[1] } })],
|
||||||
|
|
@ -204,12 +206,21 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
|
|
||||||
// 2) LIST PAGES: legacy lists → portal lists (with tab param) or sections
|
// 2) LIST PAGES: legacy lists → portal lists (with tab param) or sections
|
||||||
const listRules: Array<[RegExp, MapResult]> = [
|
const listRules: Array<[RegExp, MapResult]> = [
|
||||||
|
// ── Canonicals for direct, public URLs ───────────────────────────────────
|
||||||
|
// https://makearmy.io/background-remover → /portal/utilities?t=background-remover
|
||||||
|
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
|
||||||
|
|
||||||
|
// https://makearmy.io/buying-guide → /portal/buying-guide
|
||||||
|
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
|
||||||
|
|
||||||
|
// ── Existing rules (keep your current ones below) ────────────────────────
|
||||||
// Laser settings lists
|
// Laser settings lists
|
||||||
[/^\/fiber-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "fiber" } }],
|
[/^\/fiber-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "fiber" } }],
|
||||||
[/^\/uv-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "uv" } }],
|
[/^\/uv-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "uv" } }],
|
||||||
[/^\/co2-galvo-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } }],
|
[/^\/co2-galvo-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-galvo" } }],
|
||||||
[/^\/co2-ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // typo catch
|
[/^\/co2-ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // typo catch
|
||||||
[/^\/co2-gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }],
|
[/^\/co2-gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }],
|
||||||
|
[/^\/co2ganry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // typo catch variant (if needed)
|
||||||
[/^\/co2gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // old alias
|
[/^\/co2gantry-settings\/?$/i, { pathname: "/portal/laser-settings", query: { t: "co2-gantry" } }], // old alias
|
||||||
|
|
||||||
// Materials lists
|
// Materials lists
|
||||||
|
|
|
||||||
1
public/svgnest
Submodule
1
public/svgnest
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 1248dc21efd3f90d1aa52ba5785e27e5217ed2c9
|
||||||
Loading…
Add table
Add a link
Reference in a new issue