From f08a7456ee24ddcc4d0576e88bbd2475006f41fd Mon Sep 17 00:00:00 2001 From: makearmy Date: Sun, 12 Oct 2025 22:24:23 -0400 Subject: [PATCH] completely refactored utilities for direct rendering, killed iframes --- app/background-remover/page.tsx | 539 ------------------ app/background-remover/page.tsx.bak | 456 --------------- app/files/__pycache__/main.cpython-313.pyc | Bin 2304 -> 0 bytes app/files/layout.tsx | 17 - app/files/main.py | 44 -- app/files/page.tsx | 135 ----- app/laser-toolkit/page.tsx | 124 ---- app/portal/buying-guide/page.tsx | 16 + components/PortalTabs.tsx | 11 +- .../buying-guide/.bak}/finder/page.tsx | 0 .../buying-guide/.bak}/layout.tsx | 0 .../buying-guide/.bak}/page.tsx | 0 .../buying-guide/.bak}/product/[id]/page.tsx | 0 .../product/[id]/page.tsx.bak.1755462414 | 0 components/buying-guide/BuyingGuideList.tsx | 190 ++++++ .../buying-guide/BuyingGuideProduct.tsx | 96 ++++ components/buying-guide/LaserFinderPanel.tsx | 171 ++++++ components/buying-guide/dx.ts | 12 + components/portal/BuyingGuideSwitcher.tsx | 60 ++ components/portal/LaserToolkitSwitcher.tsx | 58 ++ components/portal/UtilitySwitcher.tsx | 85 ++- .../utilities/BackgroundRemoverPanel.tsx | 528 +++++++++++++++++ components/utilities/SVGNestPanel.tsx | 149 +++++ .../utilities/files/FileBrowserPanel.tsx | 166 ++++++ components/utilities/files/FilePreview.tsx | 56 ++ components/utilities/files/FilesTable.tsx | 112 ++++ components/utilities/files/api.ts | 81 +++ .../laser-toolkit/_lib/conversions.ts | 0 .../laser-toolkit/beam-spot-size/page.tsx | 0 .../laser-toolkit/dpi-lpi-dpcm/page.tsx | 0 .../laser-toolkit/hatch-overlap/page.tsx | 0 .../laser-toolkit/job-time-estimator/page.tsx | 0 .../laser-toolkit/power-lens-scaler/page.tsx | 0 .../laser-toolkit/pulse-overlap/page.tsx | 0 .../utilities/laser-toolkit/registry.ts | 52 ++ middleware.ts | 15 +- public/svgnest | 1 + 37 files changed, 1824 insertions(+), 1350 deletions(-) delete mode 100644 app/background-remover/page.tsx delete mode 100644 app/background-remover/page.tsx.bak delete mode 100644 app/files/__pycache__/main.cpython-313.pyc delete mode 100644 app/files/layout.tsx delete mode 100644 app/files/main.py delete mode 100644 app/files/page.tsx delete mode 100644 app/laser-toolkit/page.tsx create mode 100644 app/portal/buying-guide/page.tsx rename {app/buying-guide => components/buying-guide/.bak}/finder/page.tsx (100%) rename {app/buying-guide => components/buying-guide/.bak}/layout.tsx (100%) rename {app/buying-guide => components/buying-guide/.bak}/page.tsx (100%) rename {app/buying-guide => components/buying-guide/.bak}/product/[id]/page.tsx (100%) rename {app/buying-guide => components/buying-guide/.bak}/product/[id]/page.tsx.bak.1755462414 (100%) create mode 100644 components/buying-guide/BuyingGuideList.tsx create mode 100644 components/buying-guide/BuyingGuideProduct.tsx create mode 100644 components/buying-guide/LaserFinderPanel.tsx create mode 100644 components/buying-guide/dx.ts create mode 100644 components/portal/BuyingGuideSwitcher.tsx create mode 100644 components/portal/LaserToolkitSwitcher.tsx create mode 100644 components/utilities/BackgroundRemoverPanel.tsx create mode 100644 components/utilities/SVGNestPanel.tsx create mode 100644 components/utilities/files/FileBrowserPanel.tsx create mode 100644 components/utilities/files/FilePreview.tsx create mode 100644 components/utilities/files/FilesTable.tsx create mode 100644 components/utilities/files/api.ts rename {app => components/utilities}/laser-toolkit/_lib/conversions.ts (100%) rename {app => components/utilities}/laser-toolkit/beam-spot-size/page.tsx (100%) rename {app => components/utilities}/laser-toolkit/dpi-lpi-dpcm/page.tsx (100%) rename {app => components/utilities}/laser-toolkit/hatch-overlap/page.tsx (100%) rename {app => components/utilities}/laser-toolkit/job-time-estimator/page.tsx (100%) rename {app => components/utilities}/laser-toolkit/power-lens-scaler/page.tsx (100%) rename {app => components/utilities}/laser-toolkit/pulse-overlap/page.tsx (100%) create mode 100644 components/utilities/laser-toolkit/registry.ts create mode 160000 public/svgnest diff --git a/app/background-remover/page.tsx b/app/background-remover/page.tsx deleted file mode 100644 index e8acafc0..00000000 --- a/app/background-remover/page.tsx +++ /dev/null @@ -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 { - 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((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(null); - const [sourceUrl, setSourceUrl] = useState(null); - const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); - - const [status, setStatus] = useState>( - () => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record - ); - const [results, setResults] = useState({}); - const resultsRef = useRef({}); - useEffect(() => { - resultsRef.current = results; - }, [results]); - - const [active, setActive] = useState(null); - const [reveal, setReveal] = useState(50); - const [gpuSafe, setGpuSafe] = useState(true); - - const frameRef = useRef(null); - const draggingRef = useRef(false); - const batchBlobCache = useRef>(new Map()); - - useEffect(() => { - return () => { - revoke(sourceUrl); - Object.values(resultsRef.current).forEach((v) => revoke(v?.previewUrl)); - }; - }, [sourceUrl]); - - // ---------- Styles ---------- - const styles = ( - - ); - - // ---------- 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 => { - 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[] = []; - - 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) => { - 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 ; - } - - // ---------- Render ---------- - return ( -
- {styles} - -
- {/* Header row: title left, back button right */} -
-

Background Remover

- - Back to main - -
- - {/* Source filename */} -
- Source:{" "} - {file?.name ?? — none —} -
- - {/* Preview frame */} -
e.preventDefault()} - onDrop={onDrop} - onMouseDown={onMouseDown} - onMouseMove={onMouseMove} - onMouseLeave={onMouseUp} - onMouseUp={onMouseUp} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} - > - {/* Drop hint */} - {!sourceUrl && ( - - )} - - {/* Before/After */} - {sourceUrl && ( - <> - {/* LEFT (BEFORE) */} - Source - - {/* RIGHT (AFTER) */} - {activeResult ? ( - Result - ) : status[active as Canonical] === "pending" ? ( -
- -
- ) : null} - - {/* Divider & Thumb */} -
-
-
-
-
- - )} -
- - {/* Method options – EVEN GRID under the preview */} -
- {METHODS.map(({ key, label }) => ( - - ))} -
- - {/* Actions & global status */} -
- - - {/* GPU-safe toggle: sits next to Run All on mobile; keeps position naturally on larger screens */} - - -
- {file ? ( - pendingCount > 0 ? ( - Processing… {doneCount}/{METHODS.length} finished - ) : doneCount > 0 ? ( - Done: {doneCount} methods succeeded - ) : ( - Ready. Click Run all methods - ) - ) : ( - Drop an image to begin - )} -
- - {/* Right-side controls collapse under on mobile */} -
- setReveal(parseInt(e.target.value, 10))} - className="w-full sm:w-56" - title="Slide to compare before/after" - /> - - - - -
-
-
-
- ); -} - diff --git a/app/background-remover/page.tsx.bak b/app/background-remover/page.tsx.bak deleted file mode 100644 index 963c535a..00000000 --- a/app/background-remover/page.tsx.bak +++ /dev/null @@ -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 { - 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((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(null); - const [sourceUrl, setSourceUrl] = useState(null); - const [natural, setNatural] = useState<{ w: number; h: number } | null>(null); - - const [status, setStatus] = useState>( - () => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record - ); - const [results, setResults] = useState({}); - const resultsRef = useRef({}); - useEffect(() => { - resultsRef.current = results; - }, [results]); - - const [active, setActive] = useState(null); - const [reveal, setReveal] = useState(50); - - const frameRef = useRef(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 = ( - - ); - - 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[] = []; - - 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) => { - 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 ; - } - - return ( -
- {styles} - - {/* Header row: title left, back button right */} -
-
-

Background Remover

- - Back to main - -
- - {/* Source filename */} -
- Source:{" "} - {file?.name ?? — none —} -
- - {/* Frame */} -
e.preventDefault()} - onDrop={onDrop} - onMouseDown={onMouseDown} - onMouseMove={onMouseMove} - onMouseLeave={onMouseUp} - onMouseUp={onMouseUp} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} - > - {/* Drop hint */} - {!sourceUrl && ( - - )} - - {/* Before/After */} - {sourceUrl && ( - <> - {/* LEFT (BEFORE) */} - Source - - {/* RIGHT (AFTER) */} - {activeResult ? ( - Result - ) : status[active as Canonical] === "pending" ? ( -
- -
- ) : null} - - {/* Divider & Thumb */} -
-
-
-
-
- - )} -
- - {/* Method options – EVEN GRID under the preview */} -
- {METHODS.map(({ key, label }) => ( - - ))} -
- - {/* Actions & global status */} -
- - - {/* GPU-safe toggle: sits next to Run All on mobile; keeps position naturally on larger screens */} - - -
- {file ? ( - pendingCount > 0 ? ( - - Processing… {doneCount}/{METHODS.length} finished - - ) : doneCount > 0 ? ( - Done: {doneCount} methods succeeded - ) : ( - Ready. Click Run all methods - ) - ) : ( - Drop an image to begin - )} -
- - {/* Right-side controls collapse under on mobile */} -
- {/* On mobile, let the slider span full width to avoid crowding */} - setReveal(parseInt(e.target.value, 10))} - className="w-full sm:w-56" - title="Slide to compare before/after" - /> - -
-
-
-
- ); -} - diff --git a/app/files/__pycache__/main.cpython-313.pyc b/app/files/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 92f74a62518b3471aa0325f966ece24f61e31b4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2304 zcmcIlO>7fK6rTO{&wA~(12yCanSeuGniy!18p4k#BqdJjq+YC2#cH&~n`F^uZOv|y zlB%syD*-2vS|oGg0EcjD)oXjHihA}c5_YFWbyY4%Jva)-9y+u3+G(Y#aOkWw@6CJj z-n@DHy*JyU=m&WG7#v*sh6UhvvT%pD#ccnHnL9uRGBpWO6r@r#q*DxJVnjwyvMCO7 z4$n;TDG&5GJUi)4`Jm6?xk*3t=cs;gyyX%KIZr>3`QubmklEW(>>%yEVou2P?NGE> z+2dO81LTuKvNso!eK|??=cHj?7P`R|_6pVZ6aE)J0oRW#=0d}?93*yatH>gkke-n< z6Abcy?}3#Muz zPS=(-Yo6JW4-WHWy`QhBNJUshq{qHHa9~g2<13LCajz ztR;xM0ze*Y3yGRnibd7PBi2xsRm7SFXdy;djf4jhC_vIpAo-!lcNrsm&cUunH7uy9 zCSpkl$X8SrHAAt~yh$JxYYueuziI;?-K>w~vZ5J7#jD6q%4E(d6WEVkk<-oZ@l*oO z10d2{&fDSss&v?v4sS|>rHQ(*t19%^LQhrbvxU9~^2Xl#A5`O~?fB{M%tt4_{j?gN zw&T;k1ba4^uhVw$T*Z5?9+XON*QC8wX}>M)e=Hri!PbP(tBq>|7R+Nfv`4!c z{DDO;*$k*G^+Lt;_hvx35dFWJ!ECz?TncA`?VGk4nH?d8!KJk?Nnr%Xai(HM!HO*u zR*XC(*0=+ENxleb6JAIv5$?hh2Da(o2fji?Q8E$0L!M0CcuLUlASO87E4uFF-=y(6 z7K8hVSag3-(@O%&$aeF6Je9!Ha8FsY!-uNU5nDR4DIIG;u-6uPtHM58*!N(5BXQrT z4xF(E&OBlsrG5xi2WISn83%%u;q!KItl}MeDn!e^uhI|GwlG>@N8thN9lu5Zl2D!< zBdadtS-@|TQq&;n_sv@nFraWtK7>o%d6rD)cF03;q-&QUDMG|sSBv-^g5=RhbiqUn zE;lDfPPP!Us9H{v5ToI0Ce;SXZ65+Bnr1e!O{ zqffwVPr#lhAo?@#{l<%*X(i?-FFqU;#;=()~0yA7lIbvV&#W$&e2F+6^%knnB NglsyrMFdXRe*uXv(-Hsx diff --git a/app/files/layout.tsx b/app/files/layout.tsx deleted file mode 100644 index 2fbf4bc7..00000000 --- a/app/files/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Suspense } from "react"; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - ); -} diff --git a/app/files/main.py b/app/files/main.py deleted file mode 100644 index ae6e6381..00000000 --- a/app/files/main.py +++ /dev/null @@ -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) - diff --git a/app/files/page.tsx b/app/files/page.tsx deleted file mode 100644 index c75fd226..00000000 --- a/app/files/page.tsx +++ /dev/null @@ -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(null); - const [error, setError] = useState(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 ( -
- -
- Path: - {path} - {upPath && ( - <> - - - Up one level - - - )} -
- - {loading &&
Loading…
} - {error && ( -
- Error loading files: {error} -
- )} - - {!loading && !error && items && ( - - - - - - - - - - - - {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 ( - - - - - - - - ); - })} - -
NameTypeSizeModified
- {it.name} - {it.isDir ? "Dir" : "File"} - {it.isDir ? "-" : `${it.size.toLocaleString()} B`} - - {new Date(it.mtime).toLocaleString()} - - {!it.isDir && dl && ( - - Download - - )} -
- )} -
- ); -} - diff --git a/app/laser-toolkit/page.tsx b/app/laser-toolkit/page.tsx deleted file mode 100644 index 6e1ed08b..00000000 --- a/app/laser-toolkit/page.tsx +++ /dev/null @@ -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>; -}; - -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 ( -
- {/* Header */} -
-
-

Laser Toolkit

-

- Handy calculators and converters for daily laser work —{" "} - hover for details. -

-
- - -
- - {/* Grid of tools */} -
- {TOOLS.map((tool) => ( - - - -
-
- -
-
- {tool.title} - - {/* Full description on hover (no truncation) */} -

- {tool.description} -

-
- -
-
-
- - ))} -
-
- ); -} - diff --git a/app/portal/buying-guide/page.tsx b/app/portal/buying-guide/page.tsx new file mode 100644 index 00000000..40258383 --- /dev/null +++ b/app/portal/buying-guide/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/components/PortalTabs.tsx b/components/PortalTabs.tsx index c2d313a4..e9415a97 100644 --- a/components/PortalTabs.tsx +++ b/components/PortalTabs.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; 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 = [ { href: "/portal", label: "Home" }, @@ -12,6 +12,7 @@ const tabs = [ { href: "/portal/laser-sources", label: "Laser Sources" }, { href: "/portal/materials", label: "Materials" }, { href: "/portal/projects", label: "Projects" }, +{ href: "/portal/buying-guide", label: "Buying Guide" }, // ⬅️ NEW { href: "/portal/utilities", label: "Utilities" }, { href: "/portal/account", label: "Account" }, ]; @@ -32,9 +33,7 @@ export default function PortalTabs() { href={t.href} className={cn( "px-3 py-1.5 text-sm rounded-md transition", - active - ? "bg-primary text-primary-foreground" - : "hover:bg-muted" + active ? "bg-primary text-primary-foreground" : "hover:bg-muted" )} > {t.label} @@ -42,9 +41,7 @@ export default function PortalTabs() { ); })} -
- MakerDash -
+
MakerDash
); } diff --git a/app/buying-guide/finder/page.tsx b/components/buying-guide/.bak/finder/page.tsx similarity index 100% rename from app/buying-guide/finder/page.tsx rename to components/buying-guide/.bak/finder/page.tsx diff --git a/app/buying-guide/layout.tsx b/components/buying-guide/.bak/layout.tsx similarity index 100% rename from app/buying-guide/layout.tsx rename to components/buying-guide/.bak/layout.tsx diff --git a/app/buying-guide/page.tsx b/components/buying-guide/.bak/page.tsx similarity index 100% rename from app/buying-guide/page.tsx rename to components/buying-guide/.bak/page.tsx diff --git a/app/buying-guide/product/[id]/page.tsx b/components/buying-guide/.bak/product/[id]/page.tsx similarity index 100% rename from app/buying-guide/product/[id]/page.tsx rename to components/buying-guide/.bak/product/[id]/page.tsx diff --git a/app/buying-guide/product/[id]/page.tsx.bak.1755462414 b/components/buying-guide/.bak/product/[id]/page.tsx.bak.1755462414 similarity index 100% rename from app/buying-guide/product/[id]/page.tsx.bak.1755462414 rename to components/buying-guide/.bak/product/[id]/page.tsx.bak.1755462414 diff --git a/components/buying-guide/BuyingGuideList.tsx b/components/buying-guide/BuyingGuideList.tsx new file mode 100644 index 00000000..3bd77c45 --- /dev/null +++ b/components/buying-guide/BuyingGuideList.tsx @@ -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([]); + const [subs, setSubs] = useState([]); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [catId, setCatId] = useState("all"); + const [subId, setSubId] = useState("all"); + const [q, setQ] = useState(""); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const [catRes, subRes] = await Promise.all([ + dxGet("items/bg_cat", { fields: "id,name", limit: 500, sort: "name" }), + dxGet("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("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 ( +
+ {/* Controls */} +
+
+ + +
+ +
+ + +
+ +
+ + setQ(e.target.value)} + /> +
+
+ + {/* Results */} + {loading ? ( +
Loading…
+ ) : entries.length === 0 ? ( +
No results.
+ ) : ( + + )} +
+ ); +} diff --git a/components/buying-guide/BuyingGuideProduct.tsx b/components/buying-guide/BuyingGuideProduct.tsx new file mode 100644 index 00000000..af02851e --- /dev/null +++ b/components/buying-guide/BuyingGuideProduct.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await dxGet("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
Loading…
; + if (!rec) return
Not found.
; + + 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 ( +
+
+

+ {rec.brand ? `${rec.brand} ` : ""}{title} +

+ + Open full page + +
+ + {images.length > 0 && ( +
+ {images.map((id) => ( + // eslint-disable-next-line @next/next/no-img-element + + ))} +
+ )} + + {rec.body && ( +
+ )} + + {rec.specs && ( +
+            {JSON.stringify(rec.specs, null, 2)}
+            
+ )} +
+ ); +} diff --git a/components/buying-guide/LaserFinderPanel.tsx b/components/buying-guide/LaserFinderPanel.tsx new file mode 100644 index 00000000..55b2b813 --- /dev/null +++ b/components/buying-guide/LaserFinderPanel.tsx @@ -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("any"); + const [budget, setBudget] = useState(null); + const [minBedX, setMinBedX] = useState(null); + const [minBedY, setMinBedY] = useState(null); + const [recs, setRecs] = useState([]); + 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("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 ( +
+
+
+ + setQ(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setBudget(e.target.value ? Number(e.target.value) : null)} + /> +
+ +
+ + setMinBedX(e.target.value ? Number(e.target.value) : null)} + /> +
+
+ + setMinBedY(e.target.value ? Number(e.target.value) : null)} + /> +
+
+ + {loading ? ( +
Searching…
+ ) : recs.length === 0 ? ( +
No matching lasers.
+ ) : ( +
+ + + + + + + + + + + + {recs.map(r => ( + + + + + + + + ))} + +
LaserTypeBed (mm)Price
{r.brand} {r.model || r.title}{r.laser_type || "—"} + {(r.bed_size_x ?? "—")} × {(r.bed_size_y ?? "—")} + + {(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()}`) + : "—"} + + + View + +
+
+ )} +
+ ); +} diff --git a/components/buying-guide/dx.ts b/components/buying-guide/dx.ts new file mode 100644 index 00000000..c1b7086e --- /dev/null +++ b/components/buying-guide/dx.ts @@ -0,0 +1,12 @@ +// components/utilities/buying-guide/dx.ts +export type Q = Record; + +export async function dxGet(path: string, query?: Q): Promise { + 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; +} diff --git a/components/portal/BuyingGuideSwitcher.tsx b/components/portal/BuyingGuideSwitcher.tsx new file mode 100644 index 00000000..a05cb338 --- /dev/null +++ b/components/portal/BuyingGuideSwitcher.tsx @@ -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 ( +
+
+ {TABS.map(t => ( + + ))} +
+ +
+ {active === "finder" ? : } +
+
+ ); +} diff --git a/components/portal/LaserToolkitSwitcher.tsx b/components/portal/LaserToolkitSwitcher.tsx new file mode 100644 index 00000000..34e76732 --- /dev/null +++ b/components/portal/LaserToolkitSwitcher.tsx @@ -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
No tools registered.
; + } + + const ActiveCmp = active.component; + + return ( +
+
+ {TOOLKIT_TABS.map(t => ( + + ))} +
+ +
+ +
+
+ ); +} diff --git a/components/portal/UtilitySwitcher.tsx b/components/portal/UtilitySwitcher.tsx index 7eff4ca8..66c8a5f6 100644 --- a/components/portal/UtilitySwitcher.tsx +++ b/components/portal/UtilitySwitcher.tsx @@ -2,6 +2,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -10,44 +11,70 @@ type Item = { label: string; note?: string; icon?: string; // optional icon (public/images/utils/) - 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[] = [ - // On-site (embed) + // ✅ Laser Toolkit now renders inline with sub-tabs { key: "laser-toolkit", label: "Laser Toolkit", note: "convert laser settings, interval and more", 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", label: "File Server", note: "download from our file explorer", icon: "fs.png", + component: FileBrowserPanel, href: "https://makearmy.io/files", }, -{ - key: "buying-guide", - label: "Buying Guide", - note: "reviews and listings for relevant products", - icon: "bg.png", - href: "https://makearmy.io/buying-guide", -}, + +// Buying Guide moved to main portal tab — remove here to avoid duplication +// { key: "buying-guide", ... } + +// ✅ SVGnest inline (micro-frontend wrapper) { key: "svgnest", label: "SVGnest", note: "automatically nests parts and exports svg", icon: "nest.png", + component: SVGNestPanel, href: "https://makearmy.io/svgnest", }, + +// ✅ Background Remover inline { key: "background-remover", label: "BG Remover", note: "open source background remover", icon: "bgrm.png", + component: BackgroundRemoverPanel, 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 { const u = new URL(urlStr); return u.hostname !== "makearmy.io"; @@ -96,8 +124,17 @@ function toOnsitePath(urlStr: string): string { } function Panel({ item }: { item: Item }) { - const external = isExternal(item.href); + if (item.component) { + const Cmp = item.component; + return ( +
+ {item.note ?
{item.note}
: null} + +
+ ); + } + const external = isExternal(item.href); if (external) { return (
@@ -116,10 +153,10 @@ function Panel({ item }: { item: Item }) { ); } - const src = toOnsitePath(item.href); + const src = toOnsitePath(item.href || "/"); return (
-
{item.note}
+ {item.note ?
{item.note}
: null}