completely refactored utilities for direct rendering, killed iframes

This commit is contained in:
makearmy 2025-10-12 22:24:23 -04:00
parent 12dd2c6c06
commit f08a7456ee
37 changed files with 1824 additions and 1350 deletions

View file

@ -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 dont 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() {
);
})}
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">
MakerDash
</div>
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">MakerDash</div>
</nav>
);
}

View file

@ -0,0 +1,322 @@
// app/buying-guide/finder/page.tsx
"use client";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import type { Answers, LaserType } from "@/lib/laser-finder";
import { scoreAnswers, LASER_LABEL, TYPE_INFO } from "@/lib/laser-finder";
import Link from "next/link";
export default function LaserFinderPage() {
const [result, setResult] = useState<{
top: LaserType[];
score: Record<LaserType, number>;
why: Record<LaserType, string[]>;
} | null>(null);
const { register, handleSubmit, reset, watch } = useForm<Answers>({
defaultValues: {
materials: [],
operations: [],
part_size: "medium",
detail: "medium",
throughput: "medium",
budget: "mid",
},
});
const onSubmit = (vals: Answers) => {
const { ranked, score, why } = scoreAnswers(vals);
setResult({ top: ranked.slice(0, 2), score, why });
// (Optional) later: POST vals to Directus for analytics
};
const selectedMaterials = watch("materials");
const selectedOps = watch("operations");
return (
<div className="max-w-4xl mx-auto py-8 space-y-6">
<h1 className="text-2xl font-semibold">Laser Type Finder</h1>
<p className="text-sm text-muted-foreground">
Answer a few questions and well suggest the best laser <em>types</em> for your work
with clear use-cases, materials, and cautions. No product pitchesjust guidance.
</p>
{!result && (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Materials */}
<fieldset className="border rounded p-4">
<legend className="font-medium">Materials (select all that apply)</legend>
<div className="grid sm:grid-cols-2 gap-2 mt-2">
{[
["metals_bare", "Bare metals"],
["metals_coated", "Coated/painted metals"],
["plastics", "Plastics"],
["wood_paper_leather", "Wood, paper, leather"],
["glass_ceramic", "Glass / ceramic"],
["stone", "Stone"],
["textiles", "Textiles"],
].map(([val, label]) => (
<label key={val} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={val} {...register("materials")} /> {label}
</label>
))}
</div>
{selectedMaterials?.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Tip: choose at least one material for a better match.</p>
)}
</fieldset>
{/* Operations */}
<fieldset className="border rounded p-4">
<legend className="font-medium">Typical operations (select all that apply)</legend>
<div className="grid sm:grid-cols-2 gap-2 mt-2">
{[
["deep_mark_metal", "Deep mark on metal"],
["color_mark_stainless", "Color mark stainless"],
["fine_engraving", "Fine engraving (small features)"],
["photo_engrave", "Photo engraving"],
["cut_nonmetals_thick", "Cut thick non-metals (e.g., 6+ mm acrylic/wood)"],
["cut_nonmetals_thin", "Cut thin non-metals"],
["mark_coated", "Mark coated items"],
].map(([val, label]) => (
<label key={val} className="flex items-center gap-2 text-sm">
<input type="checkbox" value={val} {...register("operations")} /> {label}
</label>
))}
</div>
{selectedOps?.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Tip: pick one or more to sharpen the recommendation.</p>
)}
</fieldset>
{/* Size / Detail / Speed / Budget */}
<div className="grid sm:grid-cols-4 gap-4">
<div>
<label className="block text-sm mb-1">Part size</label>
<select className="w-full border rounded px-2 py-1" {...register("part_size")}>
<option value="small">Small ( 200 mm)</option>
<option value="medium">Medium ( 600 mm)</option>
<option value="large">Large (&gt; 600 mm)</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Detail</label>
<select className="w-full border rounded px-2 py-1" {...register("detail")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="micro">Micro</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Throughput</label>
<select className="w-full border rounded px-2 py-1" {...register("throughput")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Budget</label>
<select className="w-full border rounded px-2 py-1" {...register("budget")}>
<option value="low">Lower</option>
<option value="mid">Mid</option>
<option value="high">Higher</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90" type="submit">
Get recommendations
</button>
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
Back to Buying Guide
</Link>
</div>
</form>
)}
{!!result && (
<div className="space-y-6">
<ResultCard
title="Top recommendation"
type={result.top[0]}
why={result.why[result.top[0]]}
/>
{result.top[1] && (
<ResultCard
title="Alternative to consider"
type={result.top[1]}
why={result.why[result.top[1]]}
secondary
/>
)}
<CompareMatrix />
<div className="flex items-center gap-3">
<button className="px-3 py-2 border rounded hover:bg-muted" onClick={() => setResult(null)}>
Start over
</button>
<button
className="px-3 py-2 border rounded hover:bg-muted"
onClick={() => { reset(); setResult(null); }}
>
New answers
</button>
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
Back to Buying Guide
</Link>
</div>
</div>
)}
</div>
);
}
function ResultCard({
title,
type,
why,
secondary,
}: {
title: string;
type: LaserType;
why?: string[];
secondary?: boolean;
}) {
const info = TYPE_INFO[type];
return (
<div className="rounded border p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
{!secondary && <span className="text-xs rounded bg-muted px-2 py-0.5">Best match</span>}
</div>
<div className="text-base font-medium">{LASER_LABEL[type]}</div>
<p className="text-sm text-muted-foreground">{info.summary}</p>
{!!why?.length && (
<div>
<div className="text-sm font-medium mb-1">Why this fits</div>
<ul className="list-disc pl-5 text-sm space-y-1">
{why.slice(0, 5).map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
<div className="grid sm:grid-cols-3 gap-3 text-sm">
<TagList label="Best for" items={info.bestFor} />
<TagList label="Materials" items={info.materials} />
<TagList label="Cautions" items={info.cautions} tone="warn" />
</div>
<div className="flex items-center gap-3 pt-1">
<Link className="text-sm underline hover:no-underline" href={info.learnLink}>
See community settings
</Link>
<Link className="text-sm underline hover:no-underline" href="/submit/settings?target=settings_fiber">
Suggest new settings
</Link>
</div>
</div>
);
}
function TagList({
label,
items,
tone,
}: {
label: string;
items: string[];
tone?: "warn";
}) {
return (
<div>
<div className="text-sm font-medium mb-1">{label}</div>
<div className="flex flex-wrap gap-2">
{items.map((t, i) => (
<span
key={i}
className={`text-xs px-2 py-1 rounded border ${
tone === "warn" ? "bg-amber-50 border-amber-200" : "bg-muted"
}`}
>
{t}
</span>
))}
</div>
</div>
);
}
function CompareMatrix() {
const rows: Array<{
k: LaserType;
label: string;
best: string[];
ok: string[];
avoid: string[];
}> = [
{
k: "fiber",
label: LASER_LABEL.fiber,
best: ["Bare metals", "Deep metal engrave", "Color marking stainless"],
ok: ["Some coated items", "Some plastics w/ additives"],
avoid: ["Thick organics cutting"],
},
{
k: "co2_gantry",
label: LASER_LABEL.co2_gantry,
best: ["Acrylic cutting", "Wood cutting/engraving", "Large panels"],
ok: ["Leathers, textiles, rubber"],
avoid: ["Bare metals (no coat)"],
},
{
k: "co2_galvo",
label: LASER_LABEL.co2_galvo,
best: ["Fast marking organics", "Photo engraving organics"],
ok: ["Coated metals/non-metals"],
avoid: ["Thick sheet cutting", "Large panels"],
},
{
k: "uv",
label: LASER_LABEL.uv,
best: ["Micro features", "Glass/ceramic/plastics marking"],
ok: ["Fine logos on coated metals"],
avoid: ["Thick cutting"],
},
];
return (
<div className="rounded border p-4">
<div className="text-sm font-medium mb-2">Compare laser types</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Type</th>
<th className="py-2 pr-3">Best for</th>
<th className="py-2 pr-3">Okay for</th>
<th className="py-2">Not ideal</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.k} className="border-b last:border-0 align-top">
<td className="py-2 pr-3 font-medium">{r.label}</td>
<td className="py-2 pr-3">{r.best.join(", ")}</td>
<td className="py-2 pr-3">{r.ok.join(", ")}</td>
<td className="py-2">{r.avoid.join(", ")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
import { Suspense } from "react";
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={null}>{children}</Suspense>;
}

View file

@ -0,0 +1,342 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
interface Entry {
id: number;
product_make: string;
product_model: string;
product_price?: string;
review_overview_text?: string;
bg_entry_sub_cat?: number;
bg_entry_cat?: number;
index?: {
id: string;
filename_disk?: string;
type?: string;
};
header?: {
id: string;
filename_disk?: string;
type?: string;
};
}
interface SubCategory {
id: number;
name: string;
bg_entry_cat?: number;
}
interface Category {
id: number;
name: string;
}
export default function BuyingGuidePage() {
const searchParams = useSearchParams();
const initialQuery = searchParams.get("query") || "";
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [entries, setEntries] = useState<Entry[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [subcategories, setSubcategories] = useState<SubCategory[]>([]);
const [selectedCat, setSelectedCat] = useState("");
const [selectedSubCat, setSelectedSubCat] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
const fetchData = async () => {
try {
const [entriesRes, catRes, subCatRes] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_entries?fields=id,index.id,index.filename_disk,index.type,header.id,header.filename_disk,product_make,product_model,product_price,review_overview_text,bg_entry_cat,bg_entry_sub_cat&limit=-1&sort[]=sort`),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_cat?fields=id,name&limit=-1`),
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/bg_sub_cat?fields=id,name,bg_entry_cat&limit=-1`),
]);
const [entriesData, catData, subCatData] = await Promise.all([
entriesRes.json(),
catRes.json(),
subCatRes.json(),
]);
setEntries(entriesData?.data || []);
setCategories(catData?.data || []);
setSubcategories(subCatData?.data || []);
setLoading(false);
} catch (err) {
console.error("Error fetching data:", err);
setLoading(false);
}
};
fetchData();
}, []);
const normalize = (str: string) => str?.toLowerCase().replace(/[_\s]/g, "");
const filtered = useMemo(() => {
const q = normalize(debouncedQuery);
return entries.filter((entry) => {
const catMatch = selectedCat ? entry.bg_entry_cat === parseInt(selectedCat) : true;
const subCatMatch = selectedSubCat ? entry.bg_entry_sub_cat === parseInt(selectedSubCat) : true;
const searchMatch = q
? [entry.product_make, entry.product_model, entry.review_overview_text].some((field) =>
normalize(field || "").includes(q)
)
: true;
return catMatch && subCatMatch && searchMatch;
});
}, [entries, debouncedQuery, selectedCat, selectedSubCat]);
const filteredSubcategories = useMemo(() => {
return selectedCat
? subcategories.filter((sub) => sub.bg_entry_cat === parseInt(selectedCat))
: subcategories;
}, [subcategories, selectedCat]);
const featuredEntry = useMemo(() => {
if (!entries.length) return null;
const randomIndex = Math.floor(Math.random() * entries.length);
return entries[randomIndex];
}, [entries]);
const secondFeaturedEntry = useMemo(() => {
if (entries.length < 2) return null;
let secondIndex = Math.floor(Math.random() * entries.length);
while (entries[secondIndex].id === featuredEntry?.id) {
secondIndex = Math.floor(Math.random() * entries.length);
}
return entries[secondIndex];
}, [entries, featuredEntry]);
return (
<div className="p-6 max-w-7xl mx-auto">
<style jsx global>{`
mark {
background: #ffde59;
color: #242424;
padding: 0 2px;
border-radius: 2px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
gap: 1rem;
}
.entry-card {
display: flex;
background-color: #242424;
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
height: 150px;
}
.entry-image {
width: 150px;
height: 150px;
object-fit: cover;
}
.entry-content {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.truncate-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`}</style>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
<div className="card bg-card text-card-foreground p-4">
<h1 className="text-2xl font-bold mb-2">Buying Guide</h1>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedCat}
onChange={(e) => {
setSelectedCat(e.target.value);
setSelectedSubCat("");
}}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id.toString()}>
{cat.name}
</option>
))}
</select>
<select
className="w-full border rounded px-3 py-2 mb-2"
value={selectedSubCat}
onChange={(e) => setSelectedSubCat(e.target.value)}
>
<option value="">All Subcategories</option>
{filteredSubcategories.map((sub) => (
<option key={sub.id} value={sub.id.toString()}>
{sub.name}
</option>
))}
</select>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products by make, model, etc..."
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
/>
<p className="text-sm text-muted-foreground mb-2">
Discover reviewed laser products and accessories.
</p>
<a
href="/"
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm"
>
Back to Main Menu
</a>
</div>
{[featuredEntry, secondFeaturedEntry].map((entry, idx) => (
entry && (
<div key={idx} className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Featured Product</h2>
{entry.header?.filename_disk ? (
<Image
src={`https://forms.lasereverything.net/assets/${entry.header.filename_disk}`}
alt="Header image"
width={800}
height={100}
className="w-full h-[100px] object-cover mb-2 rounded-md"
unoptimized
/>
) : (
<div className="w-full h-[100px] bg-zinc-800 flex items-center justify-center text-zinc-400 text-sm rounded-md mb-2">
No Header
</div>
)}
<Link
href={`/buying-guide/product/${entry.id}`}
className="text-accent font-semibold text-lg hover:underline"
>
{entry.product_make} {entry.product_model}
</Link>
{entry.product_price && (
<p className="text-sm text-white">Starting at {entry.product_price}</p>
)}
<p className="text-sm text-muted-foreground mt-1">
{entry.review_overview_text?.slice(0, 140)}...
</p>
</div>
)
))}
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Popular Categories</h2>
<ul className="text-sm space-y-1">
{categories.slice(0, 5).map((cat) => (
<li key={cat.id}>
<button
onClick={() => {
setSelectedCat(cat.id.toString());
setSelectedSubCat("");
}}
className="text-accent hover:underline"
>
{cat.name}
</button>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">Recently Added</h2>
<ul className="text-sm space-y-1">
{entries.slice(0, 3).map((e) => (
<li key={e.id}>
<Link href={`/buying-guide/product/${e.id}`} className="text-accent hover:underline">
{e.product_make} {e.product_model}
</Link>
</li>
))}
</ul>
</div>
<div className="card bg-card text-card-foreground p-4">
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
<p className="text-sm text-muted-foreground">
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations. Use the filters and search to find what youre looking for!
</p>
</div>
</div>
<hr className="my-8 border-border" />
{loading ? (
<p className="text-muted">Loading entries...</p>
) : filtered.length === 0 ? (
<p className="text-muted">No entries found.</p>
) : (
<div className="card-grid">
{filtered.map((entry) => {
const filename = entry.index?.filename_disk;
return (
<div key={entry.id} className="entry-card">
{filename ? (
<Image
src={`https://forms.lasereverything.net/assets/${filename}`}
alt={`${entry.product_make} ${entry.product_model}`}
width={150}
height={150}
className="entry-image"
unoptimized
/>
) : (
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">
No Image
</div>
)}
<div className="entry-content">
<div>
<p className="text-sm font-medium text-muted-foreground truncate-title">
{entry.product_make}
</p>
<Link
href={`/buying-guide/product/${entry.id}`}
className="text-lg font-semibold text-accent underline truncate-title"
title={entry.product_model}
>
{entry.product_model}
</Link>
{entry.product_price !== undefined && (
<p className="text-sm text-foreground mt-1 font-medium">
Starting at {entry.product_price}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{entry.review_overview_text?.slice(0, 120)}...
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
// app/buying-guide/product/[id]/page.tsx
import Link from "next/link";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
async function getEntry(id: string) {
const res = await fetch(
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
{
cache: "no-store",
}
);
if (!res.ok) {
const error = await res.text();
console.error(`Failed to fetch entry: ${error}`);
throw new Error(`Error fetching entry ${id}`);
}
const { data } = await res.json();
return data;
}
export default async function ProductDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = (await params).id;
const entry = await getEntry(id);
const avgScore =
entry?.scores?.length > 0
? (
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
entry.scores.length
).toFixed(1)
: "N/A";
const headerUrl = entry.header?.id
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
: null;
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header Banner */}
{headerUrl && (
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
<img
src={headerUrl}
alt="Header Image"
className="object-cover w-full h-full rounded-xl"
/>
</div>
)}
{/* Title */}
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
</div>
<div className="text-right">
{entry.product_price && (
<p className="text-lg text-white font-medium mt-1">
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
</p>
)}
<Link
href="/buying-guide"
className="text-sm text-blue-500 underline mt-2 inline-block"
>
Back to Buying Guide
</Link>
</div>
</div>
{/* Links & Score Summary */}
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
<div className="flex flex-col md:flex-row gap-8">
{Array.isArray(entry.links) && entry.links.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Links</h3>
<ul className="list-disc ml-6 space-y-1">
{entry.links.map((link: any, idx: number) => (
<li key={idx}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline"
>
{link.text || link.url}
</a>
{link.target && (
<span className="text-sm text-gray-500"> ({link.target})</span>
)}
</li>
))}
</ul>
</div>
)}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
<ul className="space-y-1">
{entry.scores.map((s: any, idx: number) => (
<li key={idx} className="flex justify-between">
<span>{s.cat}</span>
<span className="font-semibold">{s.value}/10</span>
</li>
))}
</ul>
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
</div>
)}
</div>
)}
{/* Overview */}
{entry.review_overview_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
</div>
)}
{/* Intro */}
{entry.review_intro_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
</div>
)}
{/* Detailed Scores */}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="space-y-4">
{entry.scores.map((s: any, idx: number) => (
<div key={idx} className="p-4 rounded border">
<p className="text-xl font-semibold">
{s.cat} <span className="text-blue-600">{s.value}/10</span>
</p>
<div className="text-sm text-gray-400">
<ReactMarkdown>{s.body}</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{/* Recommendation */}
{entry.rec_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
</div>
)}
{/* Updates */}
{entry.updates && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Updates</h3>
<ReactMarkdown>{entry.updates}</ReactMarkdown>
</div>
)}
{/* Video */}
{entry.video_review_url && (
<div>
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={entry.video_review_url.replace("watch?v=", "embed/")}
className="w-full h-96 rounded"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,188 @@
// app/buying-guide/product/[id]/page.tsx
import Link from "next/link";
import ReactMarkdown from "react-markdown";
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
const ASSET_URL = process.env.NEXT_PUBLIC_ASSET_URL;
async function getEntry(id: string) {
const res = await fetch(
`${API_URL}/items/bg_entries/${id}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,date_updated`,
{
cache: "no-store",
}
);
if (!res.ok) {
const error = await res.text();
console.error(`Failed to fetch entry: ${error}`);
throw new Error(`Error fetching entry ${id}`);
}
const { data } = await res.json();
return data;
}
export default async function ProductDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const id = params.id;
const entry = await getEntry(id);
const avgScore =
entry?.scores?.length > 0
? (
entry.scores.reduce((sum: number, s: any) => sum + Number(s.value), 0) /
entry.scores.length
).toFixed(1)
: "N/A";
const headerUrl = entry.header?.id
? `${ASSET_URL}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
: null;
return (
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header Banner */}
{headerUrl && (
<div className="w-full h-64 relative overflow-hidden rounded-xl shadow">
<img
src={headerUrl}
alt="Header Image"
className="object-cover w-full h-full rounded-xl"
/>
</div>
)}
{/* Title */}
<div className="flex justify-between items-start">
<div>
<h2 className="text-xl font-semibold text-white">{entry.product_make}</h2>
<h1 className="text-4xl font-bold text-yellow-500 mt-2">{entry.product_model}</h1>
</div>
<div className="text-right">
{entry.product_price && (
<p className="text-lg text-white font-medium mt-1">
{entry.product_price.startsWith("Starting at") ? entry.product_price : `Starting at ${entry.product_price}`}
</p>
)}
<Link
href="/buying-guide"
className="text-sm text-blue-500 underline mt-2 inline-block"
>
← Back to Buying Guide
</Link>
</div>
</div>
{/* Links & Score Summary */}
{(Array.isArray(entry.links) || Array.isArray(entry.scores)) && (
<div className="flex flex-col md:flex-row gap-8">
{Array.isArray(entry.links) && entry.links.length > 0 && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Links</h3>
<ul className="list-disc ml-6 space-y-1">
{entry.links.map((link: any, idx: number) => (
<li key={idx}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 underline"
>
{link.text || link.url}
</a>
{link.target && (
<span className="text-sm text-gray-500"> ({link.target})</span>
)}
</li>
))}
</ul>
</div>
)}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="md:w-1/2">
<h3 className="text-xl font-semibold mb-2">Score Summary</h3>
<ul className="space-y-1">
{entry.scores.map((s: any, idx: number) => (
<li key={idx} className="flex justify-between">
<span>{s.cat}</span>
<span className="font-semibold">{s.value}/10</span>
</li>
))}
</ul>
<p className="mt-2 font-bold text-right">Total: {avgScore}</p>
</div>
)}
</div>
)}
{/* Overview */}
{entry.review_overview_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Overview</h3>
<ReactMarkdown>{entry.review_overview_text}</ReactMarkdown>
</div>
)}
{/* Intro */}
{entry.review_intro_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">{`${entry.product_make}, ${entry.product_model} Review by ${entry.author}`}</h3>
<ReactMarkdown>{entry.review_intro_text}</ReactMarkdown>
</div>
)}
{/* Detailed Scores */}
{(Array.isArray(entry.scores) && entry.scores.length > 0) && (
<div className="space-y-4">
{entry.scores.map((s: any, idx: number) => (
<div key={idx} className="p-4 rounded border">
<p className="text-xl font-semibold">
{s.cat} <span className="text-blue-600">{s.value}/10</span>
</p>
<div className="text-sm text-gray-400">
<ReactMarkdown>{s.body}</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{/* Recommendation */}
{entry.rec_text && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Recommendation</h3>
<ReactMarkdown>{entry.rec_text}</ReactMarkdown>
</div>
)}
{/* Updates */}
{entry.updates && (
<div className="prose max-w-none">
<h3 className="text-xl font-semibold mb-2">Updates</h3>
<ReactMarkdown>{entry.updates}</ReactMarkdown>
</div>
)}
{/* Video */}
{entry.video_review_url && (
<div>
<h3 className="text-xl font-semibold mb-2">Video Review</h3>
<div className="aspect-w-16 aspect-h-9">
<iframe
src={entry.video_review_url.replace("watch?v=", "embed/")}
className="w-full h-96 rounded"
frameBorder="0"
allowFullScreen
/>
</div>
</div>
)}
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View file

@ -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/<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[] = [
// 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 (
<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) {
return (
<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 (
<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
key={src}
src={src}
@ -133,7 +170,7 @@ function Panel({ item }: { item: Item }) {
export default function UtilitySwitcher() {
const router = useRouter();
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 activeKey = useMemo(() => {
@ -152,23 +189,19 @@ export default function UtilitySwitcher() {
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
}
// When landing on an external tab, open it once in a new tab.
useEffect(() => {
const item = activeItem;
if (!item) return;
if (item.component) return;
const external = isExternal(item.href);
if (!external) return;
// Avoid duplicate opens in strict mode / re-renders
if (openedRef.current === item.key) return;
openedRef.current = item.key;
// Dont 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;
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
}, [activeItem?.key, activeItem?.href]);
@ -181,7 +214,8 @@ export default function UtilitySwitcher() {
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
{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 isActive = it.key === activeKey;
return (
@ -189,9 +223,8 @@ export default function UtilitySwitcher() {
key={it.key}
onClick={() => {
setTab(it.key);
if (external) {
// Also open immediately on click
window.open(it.href, "_blank", "noopener,noreferrer");
if (!isInline && external) {
window.open(it.href!, "_blank", "noopener,noreferrer");
}
}}
className={cn(
@ -214,7 +247,7 @@ export default function UtilitySwitcher() {
/>
) : null}
<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">
new tab
</span>

View 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>
);
}

View 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">
Couldnt 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();
}
});
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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";
}

View file

@ -0,0 +1,39 @@
// /var/www/makearmy.io/app/app/laser-toolkit/_lib/conversions.ts
// ---------- DPI / LPI / DPCM ----------
export function dpiToLpi(dpi: number) {
return dpi; // 1:1 if treating “lines” as rows in raster (common convention)
}
export function dpiToDpcm(dpi: number) {
return dpi / 2.54;
}
export function lpiToDpi(lpi: number) {
return lpi; // same note as above
}
export function lpiToDpcm(lpi: number) {
return lpi / 2.54;
}
export function dpcmToDpi(dpcm: number) {
return dpcm * 2.54;
}
export function dpcmToLpi(dpcm: number) {
return dpcm * 2.54;
}
// ---------- Power & Lens Scaler ----------
// Simple “keep energy density roughly constant” heuristic:
// newSpeed ≈ oldSpeed * (toPower / fromPower) * (fromField / toField)
export function scaleSpeed(
oldSpeed_mm_s: number,
fromPower_W: number,
toPower_W: number,
fromField_mm: number,
toField_mm: number
) {
if (fromPower_W <= 0 || toPower_W <= 0 || fromField_mm <= 0 || toField_mm <= 0) {
return oldSpeed_mm_s;
}
const k = (toPower_W / fromPower_W) * (fromField_mm / toField_mm);
return oldSpeed_mm_s * k;
}

View file

@ -0,0 +1,88 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
// Spot diameter (µm) ≈ 1.27 * M² * λ(µm) * f(mm) / D(mm)
export default function Page() {
const [lambdaNm, setLambdaNm] = useState("1064"); // nm (default fiber)
const [focalMm, setFocalMm] = useState("160"); // mm
const [beamDm, setBeamDm] = useState("6"); // mm (input beam diameter at lens)
const [m2, setM2] = useState("1.3");
const dUm = useMemo(() => {
const lamUm = num(lambdaNm) / 1000; // convert nm -> µm
const f = num(focalMm);
const D = num(beamDm);
const M2 = Math.max(1, num(m2));
if (lamUm <= 0 || f <= 0 || D <= 0) return 0;
return 1.27 * M2 * lamUm * (f / D);
}, [lambdaNm, focalMm, beamDm, m2]);
const dMm = dUm / 1000;
return (
<ToolShell title="Beam Spot Size">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Wavelength (nm)</div>
<Input value={lambdaNm} onChange={(e) => setLambdaNm(e.target.value)} />
<div className="mt-1 text-[11px] text-muted-foreground space-x-2">
<button type="button" className="underline" onClick={() => setLambdaNm("1064")}>
Fiber (1064 nm)
</button>
<button type="button" className="underline" onClick={() => setLambdaNm("10600")}>
CO (10600 nm)
</button>
</div>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Focal length (mm)</div>
<Input value={focalMm} onChange={(e) => setFocalMm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Beam Ø @ lens (mm)</div>
<Input value={beamDm} onChange={(e) => setBeamDm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">M²</div>
<Input value={m2} onChange={(e) => setM2(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Result</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div>
<div className="text-sm text-muted-foreground">Spot diameter</div>
<div className="text-lg">{dMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{dUm.toFixed(2)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Spot radius</div>
<div className="text-lg">{(dMm / 2).toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(dUm / 2).toFixed(2)} µm</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
export default function Page() {
const [dpi, setDpi] = useState("300");
const [lpi, setLpi] = useState("300");
const [dpcm, setDpcm] = useState("118.11");
const [active, setActive] = useState<"dpi" | "lpi" | "dpcm">("dpi");
// keep all three in sync based on the most recently edited field
useEffect(() => {
const D = num(dpi), L = num(lpi), C = num(dpcm);
if (active === "dpi") {
const d = Math.max(1e-9, D);
setDpcm((d / 2.54).toFixed(5));
setLpi(D.toFixed(2)); // LPI≈DPI for raster row spacing (workflow convention)
} else if (active === "lpi") {
const l = Math.max(1e-9, L);
setDpi(L.toFixed(2));
setDpcm((L / 2.54).toFixed(5));
} else {
const c = Math.max(1e-9, C);
setDpi((c * 2.54).toFixed(2));
setLpi((c * 2.54).toFixed(2));
}
}, [dpi, lpi, dpcm, active]);
const gapFromDpiMm = 25.4 / Math.max(1e-9, num(dpi));
const gapFromLpiMm = 25.4 / Math.max(1e-9, num(lpi));
return (
<ToolShell title="DPI / LPI / DPCM Converter">
<Card>
<CardHeader>
<CardTitle className="text-base">Values</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPI</div>
<Input
inputMode="decimal"
value={dpi}
onChange={(e) => {
setActive("dpi");
setDpi(e.target.value);
}}
/>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">LPI</div>
<Input
inputMode="decimal"
value={lpi}
onChange={(e) => {
setActive("lpi");
setLpi(e.target.value);
}}
/>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPCM</div>
<Input
inputMode="decimal"
value={dpcm}
onChange={(e) => {
setActive("dpcm");
setDpcm(e.target.value);
}}
/>
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Derived spacing</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-sm text-muted-foreground">Pixel/line gap from DPI</div>
<div className="text-lg">{gapFromDpiMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(gapFromDpiMm * 1000).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Line gap from LPI</div>
<div className="text-lg">{gapFromLpiMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{(gapFromLpiMm * 1000).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Pixels/cm from DPCM</div>
<div className="text-lg">{num(dpcm).toFixed(2)} px/cm</div>
<div className="text-xs text-muted-foreground">
{(num(dpcm) * 2.54).toFixed(2)} px/in
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,97 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
const UM_PER_INCH = 25400;
export default function Page() {
const [spotUm, setSpotUm] = useState("60");
const [gapUm, setGapUm] = useState("40");
const [lpi, setLpi] = useState("635"); // 635 LPI ≈ 40 µm gap
// Keep gap and LPI linked both ways
function onGapChange(v: string) {
setGapUm(v);
const g = num(v);
setLpi(g > 0 ? (UM_PER_INCH / g).toFixed(2) : "");
}
function onLpiChange(v: string) {
setLpi(v);
const L = num(v);
setGapUm(L > 0 ? (UM_PER_INCH / L).toFixed(2) : "");
}
const overlap = useMemo(() => {
const d = num(spotUm);
const g = num(gapUm);
if (d <= 0 || g <= 0) return 0;
return Math.max(0, Math.min(100, 100 * (1 - g / d)));
}, [spotUm, gapUm]);
const gapMm = (num(gapUm) / 1000) || 0;
const spotMm = (num(spotUm) / 1000) || 0;
return (
<ToolShell title="Hatch Overlap">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Hatch gap (µm)</div>
<Input inputMode="decimal" value={gapUm} onChange={(e) => onGapChange(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Hatch LPI</div>
<Input inputMode="decimal" value={lpi} onChange={(e) => onLpiChange(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Result</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<div>
<div className="text-sm text-muted-foreground">Overlap</div>
<div className="text-lg">{overlap.toFixed(1)}%</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Gap</div>
<div className="text-lg">{gapMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{num(gapUm).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Spot Ø</div>
<div className="text-lg">{spotMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{num(spotUm).toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">From LPI</div>
<div className="text-lg">
{(UM_PER_INCH / Math.max(1, num(lpi)) / 1000).toFixed(4)} mm
</div>
<div className="text-xs text-muted-foreground">
{(UM_PER_INCH / Math.max(1, num(lpi))).toFixed(1)} µm
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
function fmtTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return "0 s";
const s = Math.round(seconds);
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
export default function Page() {
const [mode, setMode] = useState<"raster" | "vector">("raster");
const [passes, setPasses] = useState("1");
// raster
const [width, setWidth] = useState("100"); // mm
const [height, setHeight] = useState("100");// mm
const [dpi, setDpi] = useState("300");
const [speedRaster, setSpeedRaster] = useState("800"); // mm/s
const [overheadR, setOverheadR] = useState("1.10"); // factor
// vector
const [length, setLength] = useState("500"); // mm
const [speedVector, setSpeedVector] = useState("50"); // mm/s
const [overheadV, setOverheadV] = useState("1.05"); // factor
const computed = useMemo(() => {
const p = Math.max(1, Math.round(num(passes)));
if (mode === "raster") {
const w = num(width), h = num(height), D = num(dpi), v = num(speedRaster), k = Math.max(0.5, num(overheadR));
if (w <= 0 || h <= 0 || D <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
const gapMm = 25.4 / D;
const gapUm = gapMm * 1000;
const rows = h / gapMm;
const t = rows * (w / v) * p * k;
return { t, gapMm, gapUm, rows };
} else {
const L = num(length), v = num(speedVector), k = Math.max(0.5, num(overheadV));
if (L <= 0 || v <= 0) return { t: 0, gapMm: 0, gapUm: 0, rows: 0 };
const t = (L / v) * Math.max(1, Math.round(num(passes))) * k;
return { t, gapMm: 0, gapUm: 0, rows: 0 };
}
}, [mode, passes, width, height, dpi, speedRaster, overheadR, length, speedVector, overheadV]);
return (
<ToolShell title="Job Time Estimator">
<Card>
<CardHeader>
<CardTitle className="text-base">Mode</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<label className="text-[11px] sm:text-xs col-span-2 sm:col-span-1">
<div className="mb-1 text-muted-foreground">Type</div>
<select
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
value={mode}
onChange={(e) => (setMode(e.target.value as any))}
>
<option value="raster">Raster</option>
<option value="vector">Vector</option>
</select>
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Passes</div>
<Input inputMode="numeric" value={passes} onChange={(e) => setPasses(e.target.value)} />
</label>
</CardContent>
</Card>
{mode === "raster" ? (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Raster Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-5">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Width (mm)</div>
<Input value={width} onChange={(e) => setWidth(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Height (mm)</div>
<Input value={height} onChange={(e) => setHeight(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">DPI</div>
<Input value={dpi} onChange={(e) => setDpi(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input value={speedRaster} onChange={(e) => setSpeedRaster(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Overhead factor</div>
<Input value={overheadR} onChange={(e) => setOverheadR(e.target.value)} />
</label>
</CardContent>
</Card>
) : (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Vector Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Total path length (mm)</div>
<Input value={length} onChange={(e) => setLength(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input value={speedVector} onChange={(e) => setSpeedVector(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Overhead factor</div>
<Input value={overheadV} onChange={(e) => setOverheadV(e.target.value)} />
</label>
</CardContent>
</Card>
)}
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Estimate</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-sm text-muted-foreground">Estimated time</div>
<div className="text-lg">{fmtTime(computed.t)}</div>
</div>
{mode === "raster" && (
<>
<div>
<div className="text-sm text-muted-foreground">Scan gap</div>
<div className="text-lg">{computed.gapMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{computed.gapUm.toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Line count</div>
<div className="text-lg">{computed.rows.toFixed(0)}</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Footnote */}
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
<span className="font-semibold">Overhead factor*</span> accounts for real-world slowdowns:
acceleration/decelleration, jump moves, polygon delays, laser on/off timing, overscan,
bidirectional settle time, and controller latency.{" "}
<span className="font-semibold">Typical values:</span> Vector cuts/marks{" "}
<span className="font-medium">1.051.15</span> (simple paths, long runs closer to 1.05; tiny
segments or lots of jumps closer to 1.15). Raster engraving{" "}
<span className="font-medium">1.101.40</span> (lower DPI and long sweeps near 1.10;
very high DPI or short scan width near 1.301.40). Galvo systems often have lower overhead
at small sizes; gantry systems tend to have higher overhead at high DPI/short strokes.
</p>
</ToolShell>
);
}

View file

@ -0,0 +1,378 @@
'use client';
import { useMemo, useState } from 'react';
import ToolShell from '@/components/toolkit/ToolShell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
type Mode = 'vector' | 'raster' | 'irradiance' | 'pulse';
function num(v: string, d = 0): number {
const n = Number(v);
return Number.isFinite(n) ? n : d;
}
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
/** Default curve parameters based on rated power (very rough, editable). */
function defaultCurveForRatedW(W: number) {
// Peak frequency guess (kHz). Tune these to your hardware fleet.
let fPeak = 50;
if (W <= 35) fPeak = 25;
else if (W <= 60) fPeak = 50;
else if (W <= 90) fPeak = 75;
else fPeak = 100;
// Log-normal width parameter (dimensionless). Smaller = narrower peak.
const sigma = 0.35;
return { fPeak, sigma };
}
/** Log-normal shaped efficiency curve normalized to 1 at fPeak. */
function etaOfF(f_kHz: number, fPeak_kHz: number, sigma: number) {
const f = Math.max(f_kHz, 0.1);
const r = Math.log(f / Math.max(fPeak_kHz, 0.1));
const eta = Math.exp(-0.5 * (r / Math.max(sigma, 0.05)) ** 2);
// Keep within [0.1, 1] to avoid absurd zeros; adjust if you want tails to hit 0.
return clamp(eta, 0.1, 1);
}
/** Area factor from field (proxy for spot area scaling) */
function areaFactorFromField(fieldSrc: number, fieldDst: number) {
if (fieldSrc <= 0 || fieldDst <= 0) return 1;
const r = fieldDst / fieldSrc;
return r * r;
}
export default function Page() {
// MODE
const [mode, setMode] = useState<Mode>('vector');
// SOURCE machine/lens
const [wSrc, setWSrc] = useState('100'); // rated W
const [pSrc, setPSrc] = useState('50'); // %
const [vSrc, setVSrc] = useState('300'); // mm/s
const [hSrc, setHSrc] = useState('0.1'); // mm (raster line spacing)
const [fSrc, setFSrc] = useState('30'); // kHz
const [tauSrc, setTauSrc] = useState('100'); // ns pulse width
const [fieldSrc, setFieldSrc] = useState('110'); // mm
// DEST machine/lens
const [wDst, setWDst] = useState('50'); // rated W
const [vDst, setVDst] = useState('300'); // mm/s
const [hDst, setHDst] = useState('0.1'); // mm
const [fDst, setFDst] = useState('30'); // kHz
const [tauDst, setTauDst] = useState('100'); // ns
const [fieldDst, setFieldDst] = useState('70'); // mm
// Curve tuning / advanced
const [advanced, setAdvanced] = useState(false);
const srcDefaults = defaultCurveForRatedW(num(wSrc, 50));
const dstDefaults = defaultCurveForRatedW(num(wDst, 50));
const [fPeakSrc, setFPeakSrc] = useState(String(srcDefaults.fPeak));
const [sigmaSrc, setSigmaSrc] = useState(String(srcDefaults.sigma));
const [fPeakDst, setFPeakDst] = useState(String(dstDefaults.fPeak));
const [sigmaDst, setSigmaDst] = useState(String(dstDefaults.sigma));
// Prefer adjusting speed/freq instead of exceeding 100% power
const [preferSpeedAdjust, setPreferSpeedAdjust] = useState(true);
const result = useMemo(() => {
const W1 = Math.max(num(wSrc, 1), 0.1);
const W2 = Math.max(num(wDst, 1), 0.1);
const p1 = clamp(num(pSrc, 0), 0, 100) / 100; // 0..1
const v1 = Math.max(num(vSrc, 0), 0.0001);
const v2 = Math.max(num(vDst, 0), 0.0001);
const h1 = Math.max(num(hSrc, 0), 0.000001);
const h2 = Math.max(num(hDst, 0), 0.000001);
const f1k = Math.max(num(fSrc, 0), 0.1);
const f2k = Math.max(num(fDst, 0), 0.1);
const tau1_ns = Math.max(num(tauSrc, 0), 0.1);
const tau2_ns = Math.max(num(tauDst, 0), 0.1);
const aFac = areaFactorFromField(num(fieldSrc, 0), num(fieldDst, 0));
const fpk1 = Math.max(num(fPeakSrc, defaultCurveForRatedW(W1).fPeak), 0.1);
const sig1 = Math.max(num(sigmaSrc, defaultCurveForRatedW(W1).sigma), 0.05);
const fpk2 = Math.max(num(fPeakDst, defaultCurveForRatedW(W2).fPeak), 0.1);
const sig2 = Math.max(num(sigmaDst, defaultCurveForRatedW(W2).sigma), 0.05);
// Efficiency factors (0..1)
const eta1 = etaOfF(f1k, fpk1, sig1);
const eta2 = etaOfF(f2k, fpk2, sig2);
// Effective average power (W) after frequency efficiency
const P1eff = W1 * p1 * eta1;
let p2Frac = p1; // destination power fraction (0..1)
let suggestedSpeed: number | undefined;
let suggestedFreq_kHz: number | undefined;
// Helper: compute required P2eff for each match, then map to power%
const powerPercentFromEff = (P2effReq: number) => {
// P2eff = W2 * p2 * eta2 => p2 = P2eff / (W2*eta2)
return P2effReq / (W2 * eta2);
};
if (mode === 'vector') {
// Match energy per length: P1eff / v1 = P2eff / v2
const P2effReq = P1eff * (v2 / v1);
p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) / (W1 * eta1 * p1); // from p2<=1
p2Frac = 1;
}
} else if (mode === 'raster') {
// Match energy per area: P1eff/(v1*h1) = P2eff/(v2*h2)
const P2effReq = P1eff * ((v2 * h2) / (v1 * h1));
p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) {
suggestedSpeed = v1 * (W2 * eta2) * (h1 / h2) / (W1 * eta1 * p1);
p2Frac = 1;
}
} else if (mode === 'irradiance') {
// Match irradiance: (P1eff/A1) = (P2eff/A2) => P2eff = P1eff*(A2/A1)
const P2effReq = P1eff * aFac;
p2Frac = powerPercentFromEff(P2effReq);
// no speed suggestion; consider lens/field change if >100%
} else if (mode === 'pulse') {
// Match pulse energy: Ep1 = P1eff / f1 (kHz → Hz)
const f1 = f1k * 1e3, f2 = f2k * 1e3;
const Ep1 = P1eff / f1; // J
// Require P2eff = Ep1 * f2
const P2effReq = Ep1 * f2;
p2Frac = powerPercentFromEff(P2effReq);
if (preferSpeedAdjust && p2Frac > 1) {
// Suggest lowering f2 to keep p2<=1: P2eff_max = W2*eta2*1
// f2_req = P2eff_max / Ep1
const f2_req = (W2 * eta2) / Ep1; // Hz
suggestedFreq_kHz = Math.max(f2_req / 1e3, 0.1);
p2Frac = 1;
}
}
// Compute pulse metrics (for display) using **destination** settings
const p2Clamped = clamp(p2Frac, 0, 2);
const P2eff = W2 * p2Clamped * eta2;
const f2Hz = f2k * 1e3;
const tau2_s = tau2_ns * 1e-9;
const Ep2 = P2eff / f2Hz; // J
const Ppeak2 = Ep2 / Math.max(tau2_s, 1e-12); // W, shape factor ~1 assumed
return {
p2Percent: clamp(p2Clamped * 100, 0, 200),
suggestedSpeed,
suggestedFreq_kHz,
eta1,
eta2,
P1eff,
P2eff,
Ep2,
Ppeak2,
aFac,
};
}, [
mode, wSrc, wDst, pSrc, vSrc, vDst, hSrc, hDst, fSrc, fDst, tauSrc, tauDst,
fieldSrc, fieldDst, preferSpeedAdjust, fPeakSrc, sigmaSrc, fPeakDst, sigmaDst,
]);
return (
<ToolShell
title="Power, Frequency & Lens Scaler"
description="Match settings across different lasers and lenses using effective power with a frequency efficiency curve. Includes pulse width to report pulse energy and peak power."
>
<Card className="mb-6">
<CardHeader>
<CardTitle>Match Mode</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div>
<Label className="text-sm">Quantity to Match</Label>
<Select value={mode} onValueChange={(v: Mode) => setMode(v)}>
<SelectTrigger><SelectValue placeholder="Mode" /></SelectTrigger>
<SelectContent>
<SelectItem value="vector">Vector: Energy per length (J/mm)</SelectItem>
<SelectItem value="raster">Raster: Energy per area (J/mm²)</SelectItem>
<SelectItem value="irradiance">Irradiance: W/mm² (spot/field)</SelectItem>
<SelectItem value="pulse">Pulse energy: J (fiber)</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2">
<input
id="preferSpeed"
type="checkbox"
className="h-4 w-4"
checked={preferSpeedAdjust}
onChange={(e) => setPreferSpeedAdjust(e.target.checked)}
/>
<span className="text-sm">If Power % &gt; 100, prefer adjusting speed/frequency</span>
</label>
</CardContent>
</Card>
{/* Source */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Source (what you have)</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Rated power (W)</Label>
<Input value={wSrc} onChange={(e) => setWSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Power (%)</Label>
<Input value={pSrc} onChange={(e) => setPSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Frequency (kHz)</Label>
<Input value={fSrc} onChange={(e) => setFSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Pulse width (ns)</Label>
<Input value={tauSrc} onChange={(e) => setTauSrc(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
<Label className="text-sm">Speed (mm/s)</Label>
<Input value={vSrc} onChange={(e) => setVSrc(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
<Label className="text-sm">Line spacing h (mm)</Label>
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Lens field size (mm)</Label>
<Input value={fieldSrc} onChange={(e) => setFieldSrc(e.target.value)} inputMode="decimal" />
</div>
</CardContent>
<CardContent className="pt-0">
<button
className="text-xs underline text-muted-foreground"
onClick={() => setAdvanced((s) => !s)}
>
{advanced ? 'Hide' : 'Show'} advanced frequency curve
</button>
<div className={cn('mt-3 grid gap-4 md:grid-cols-3', advanced ? 'block' : 'hidden')}>
<div>
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
<Input value={fPeakSrc} onChange={(e) => setFPeakSrc(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Curve width σ (log-normal)</Label>
<Input value={sigmaSrc} onChange={(e) => setSigmaSrc(e.target.value)} inputMode="decimal" />
</div>
<div className="flex items-end text-xs text-muted-foreground">
η(f) is log-normal; 1.0 at fₚ, rolls off by σ.
</div>
</div>
</CardContent>
</Card>
{/* Destination */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Destination (what you want to run on)</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Rated power (W)</Label>
<Input value={wDst} onChange={(e) => setWDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Frequency (kHz)</Label>
<Input value={fDst} onChange={(e) => setFDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Pulse width (ns)</Label>
<Input value={tauDst} onChange={(e) => setTauDst(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
<Label className="text-sm">Speed (mm/s)</Label>
<Input value={vDst} onChange={(e) => setVDst(e.target.value)} inputMode="decimal" />
</div>
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
<Label className="text-sm">Line spacing h (mm)</Label>
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Lens field size (mm)</Label>
<Input value={fieldDst} onChange={(e) => setFieldDst(e.target.value)} inputMode="decimal" />
</div>
</CardContent>
<CardContent className={cn('pt-0', advanced ? 'block' : 'hidden')}>
<div className="mt-3 grid gap-4 md:grid-cols-3">
<div>
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
<Input value={fPeakDst} onChange={(e) => setFPeakDst(e.target.value)} inputMode="decimal" />
</div>
<div>
<Label className="text-sm">Curve width σ (log-normal)</Label>
<Input value={sigmaDst} onChange={(e) => setSigmaDst(e.target.value)} inputMode="decimal" />
</div>
<div className="flex items-end text-xs text-muted-foreground">
Adjust if you know your machines real powerfrequency curve.
</div>
</div>
</CardContent>
</Card>
{/* Result */}
<Card>
<CardHeader>
<CardTitle>Result</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold">
Suggested Power (dest): {result.p2Percent.toFixed(1)}%
</div>
{typeof result.suggestedSpeed === 'number' && mode !== 'pulse' && (
<p className="text-sm">
To keep Power 100%, try destination speed {' '}
<span className="font-medium">{result.suggestedSpeed.toFixed(1)} mm/s</span>.
</p>
)}
{typeof result.suggestedFreq_kHz === 'number' && mode === 'pulse' && (
<p className="text-sm">
To keep Power 100%, try destination frequency {' '}
<span className="font-medium">{result.suggestedFreq_kHz.toFixed(0)} kHz</span>.
</p>
)}
<div className="mt-3 grid gap-2 md:grid-cols-3 text-sm">
<div>
<div className="text-muted-foreground">η(f) source / dest</div>
<div className="font-medium">{result.eta1.toFixed(3)} / {result.eta2.toFixed(3)}</div>
</div>
<div>
<div className="text-muted-foreground">Dest pulse energy</div>
<div className="font-medium">
{(result.Ep2 >= 1e-3 ? (result.Ep2 * 1e3).toFixed(3) + ' mJ' : (result.Ep2 * 1e6).toFixed(1) + ' µJ')}
</div>
</div>
<div>
<div className="text-muted-foreground">Dest peak power</div>
<div className="font-medium">{(result.Ppeak2 / 1000).toFixed(1)} kW</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Assumptions: Effective power includes a frequency efficiency factor η(f). Peak power uses a rectangular pulse
approximation (shape factor 1). For real MOPA sources, pulse shape and
true powerfrequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves.
</p>
</CardContent>
</Card>
</ToolShell>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import { useMemo, useState } from "react";
import ToolShell from "@/components/toolkit/ToolShell";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function num(v: string) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
export default function Page() {
const [speed, setSpeed] = useState("800"); // mm/s
const [freq, setFreq] = useState("60"); // kHz
const [spotUm, setSpotUm] = useState("50");// µm
const result = useMemo(() => {
const v = num(speed); // mm/s
const f = num(freq); // kHz
const dUm = num(spotUm); // µm
if (v <= 0 || f <= 0 || dUm <= 0) {
return { spacingUm: 0, spacingMm: 0, overlapPct: 0, pulsesPerMm: 0 };
}
// distance per pulse
const spacingUm = v / f; // µm (derives from v(mm/s) / (f(kHz)*1000) * 1000)
const spacingMm = spacingUm / 1000;
const overlapPct = Math.max(0, Math.min(100, 100 * (1 - spacingUm / dUm)));
const pulsesPerMm = (f * 1000) / v;
return { spacingUm, spacingMm, overlapPct, pulsesPerMm };
}, [speed, freq, spotUm]);
return (
<ToolShell title="Pulse Overlap">
<Card>
<CardHeader>
<CardTitle className="text-base">Inputs</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Speed (mm/s)</div>
<Input inputMode="decimal" value={speed} onChange={(e) => setSpeed(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Frequency (kHz)</div>
<Input inputMode="decimal" value={freq} onChange={(e) => setFreq(e.target.value)} />
</label>
<label className="text-[11px] sm:text-xs">
<div className="mb-1 text-muted-foreground">Spot size (µm)</div>
<Input inputMode="decimal" value={spotUm} onChange={(e) => setSpotUm(e.target.value)} />
</label>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-base">Results</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-4">
<div>
<div className="text-sm text-muted-foreground">Pulse spacing</div>
<div className="text-lg">{result.spacingMm.toFixed(4)} mm</div>
<div className="text-xs text-muted-foreground">{result.spacingUm.toFixed(1)} µm</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Overlap</div>
<div className="text-lg">{result.overlapPct.toFixed(1)}%</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Pulses per mm</div>
<div className="text-lg">{result.pulsesPerMm.toFixed(1)}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Rule of thumb</div>
<div className="text-xs">
6080% overlap is common for marking; deeper engraving often higher.
</div>
</div>
</CardContent>
</Card>
</ToolShell>
);
}

View 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 }),
},
];