Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
47
app/components/PortalTabs.tsx
Normal file
47
app/components/PortalTabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// components/PortalTabs.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ href: "/portal", label: "Home" },
|
||||
{ href: "/portal/rigs", label: "Rigs" },
|
||||
{ href: "/portal/laser-settings", label: "Laser Settings" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
export default function PortalTabs() {
|
||||
const pathname = usePathname() || "/portal";
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-1 rounded-md border bg-background p-1">
|
||||
{tabs.map((t) => {
|
||||
const active =
|
||||
pathname === t.href ||
|
||||
(t.href !== "/portal" && pathname.startsWith(t.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition",
|
||||
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">MakerDash</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
53
app/components/SignOutButton.tsx
Normal file
53
app/components/SignOutButton.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
/** Where to send users after logout */
|
||||
redirectTo?: string; // default /auth/sign-in
|
||||
};
|
||||
|
||||
export default function SignOutButton({
|
||||
className,
|
||||
redirectTo = "/auth/sign-in",
|
||||
}: Props) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
async function onClick() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include", // make sure cookies are cleared
|
||||
});
|
||||
|
||||
// include ?next= so they can land back here after re-auth if desired
|
||||
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "?next=/portal";
|
||||
router.push(redirectTo + next);
|
||||
router.refresh();
|
||||
} catch {
|
||||
router.push(redirectTo);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
86
app/components/account/AvatarUploader.tsx
Normal file
86
app/components/account/AvatarUploader.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// components/account/AvatarUploader.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function AvatarUploader({
|
||||
avatarId,
|
||||
onUpdated,
|
||||
}: {
|
||||
avatarId?: string | null;
|
||||
onUpdated?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = useMemo(
|
||||
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
|
||||
[]
|
||||
);
|
||||
const currentUrl = avatarId ? `${API_BASE}/assets/${avatarId}` : null;
|
||||
|
||||
const onUpload = async () => {
|
||||
setMsg(null);
|
||||
if (!file) {
|
||||
setMsg("Choose a file first.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file, file.name);
|
||||
|
||||
const r = await fetch("/api/account/avatar", { method: "POST", body: fd });
|
||||
if (r.status === 401) {
|
||||
location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`);
|
||||
return;
|
||||
}
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Upload failed");
|
||||
return;
|
||||
}
|
||||
setMsg("Avatar updated.");
|
||||
setFile(null);
|
||||
onUpdated?.();
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Avatar</h3>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||
{currentUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={currentUrl} alt="Avatar" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs opacity-60">No Avatar</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="text-sm"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={busy || !file}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/components/account/ConfirmIdentity.tsx
Normal file
63
app/components/account/ConfirmIdentity.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// components/account/ConfirmIdentity.tsx
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ConfirmIdentity({
|
||||
defaultIdentifier,
|
||||
onSuccess,
|
||||
}: {
|
||||
defaultIdentifier: string; // prefill with username or email you show on the page
|
||||
onSuccess: () => void; // called after re-auth succeeds
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [identifier, setIdentifier] = useState(defaultIdentifier);
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/reconfirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j?.error || "Failed");
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
onSuccess(); // now do the sensitive call
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Re-auth failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* wherever you need the step-up, render a button that opens this */}
|
||||
<button className="btn" onClick={() => setOpen(true)}>Confirm it’s you</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/50 grid place-items-center">
|
||||
<div className="bg-card border rounded-lg p-4 w-full max-w-sm">
|
||||
<h3 className="font-semibold mb-2">Confirm it’s you</h3>
|
||||
<label className="block text-sm mb-1">Email or Username</label>
|
||||
<input className="input w-full mb-2" value={identifier} onChange={e=>setIdentifier(e.target.value)} />
|
||||
<label className="block text-sm mb-1">Password</label>
|
||||
<input className="input w-full mb-3" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
||||
{err && <div className="text-red-600 text-sm mb-2">{err}</div>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn" onClick={()=>setOpen(false)} disabled={busy}>Cancel</button>
|
||||
<button className="btn-primary" onClick={submit} disabled={busy}>{busy ? "Checking…" : "Continue"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
app/components/account/ConnectKofi.tsx
Normal file
137
app/components/account/ConnectKofi.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// components/account/ConnectKofi.tsx
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export default function ConnectKofi({
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const [value, setValue] = useState(email || "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canSubmit = useMemo(
|
||||
() => !!value && /\S+@\S+\.\S+/.test(value),
|
||||
[value]
|
||||
);
|
||||
|
||||
const startClaim = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/claim/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// if your auth middleware expects anything, set it here; otherwise cookies suffice
|
||||
"x-user-id": userId ?? "",
|
||||
"x-user-email": email ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: value }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({} as any));
|
||||
if (!res.ok) {
|
||||
// Show specific errors where helpful
|
||||
const detail = j?.detail || j?.error || res.statusText;
|
||||
throw new Error(
|
||||
j?.error === "not_found"
|
||||
? "We don’t have any Ko-fi records for that email yet. If you’re sure it’s correct, try again after your next Ko-fi payment or after we run the backfill."
|
||||
: String(detail || "Failed to start verification")
|
||||
);
|
||||
}
|
||||
if (j?.alreadyLinked) {
|
||||
setMsg("This Ko-fi email is already linked to your account.");
|
||||
} else {
|
||||
setMsg(
|
||||
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Something went wrong.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [value, userId, email]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/unlink", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-id": userId ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(t || "Unlink failed");
|
||||
}
|
||||
setMsg("Ko-fi has been unlinked from your account.");
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unlink failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
|
||||
<p className="mb-3 text-sm opacity-80">
|
||||
Enter the email you use on Ko-fi. We’ll send a one-time verification link to confirm it’s you.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded border px-3 py-2 text-sm"
|
||||
placeholder="you@kofi-email.com"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startClaim}
|
||||
disabled={!canSubmit || busy}
|
||||
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{busy ? "Sending…" : "Send Verify Link"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unlink}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
|
||||
title="Remove the Ko-fi link from your account"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-xs opacity-70">
|
||||
Tip: after you verify, badges update automatically. If you don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
app/components/account/LinkStatus.tsx
Normal file
28
app/components/account/LinkStatus.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// /components/account/LinkStatus.tsx
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LinkStatus() {
|
||||
const sp = useSearchParams();
|
||||
const linked = sp.get("linked");
|
||||
if (linked !== "kofi") return null;
|
||||
|
||||
const isOk = sp.get("ok") === "1";
|
||||
const isErr = sp.get("error") === "1";
|
||||
|
||||
if (!isOk && !isErr) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"mb-3 rounded-md border p-3 text-sm",
|
||||
isOk
|
||||
? "border-emerald-300/50 bg-emerald-50 text-emerald-900"
|
||||
: "border-red-300/50 bg-red-50 text-red-900",
|
||||
].join(" ")}
|
||||
>
|
||||
{isOk ? "Ko-fi successfully linked to your account." : "Couldn’t verify that Ko-fi link. Please try again."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
app/components/account/PasswordChange.tsx
Normal file
148
app/components/account/PasswordChange.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// components/account/PasswordChange.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export default function PasswordChange() {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [next2, setNext2] = useState("");
|
||||
const [identifier, setIdentifier] = useState(""); // email OR username (sent to API)
|
||||
const [needIdentifier, setNeedIdentifier] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Try to auto-fill identifier from /api/account
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const me: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
const id = (me?.email || me?.username || "").trim();
|
||||
if (id) {
|
||||
setIdentifier(id);
|
||||
setNeedIdentifier(false);
|
||||
} else {
|
||||
setNeedIdentifier(true);
|
||||
}
|
||||
} catch {
|
||||
// If it fails, we'll let the user type it
|
||||
setNeedIdentifier(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSave = async () => {
|
||||
setMsg(null);
|
||||
if (next !== next2) {
|
||||
setMsg("New passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (next.length < 8) {
|
||||
setMsg("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (!identifier.trim()) {
|
||||
setNeedIdentifier(true);
|
||||
setMsg("Please enter your email or username.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch("/api/account/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current, next, identifier: identifier.trim() }),
|
||||
});
|
||||
|
||||
const j = await r.json().catch(() => ({} as any));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error ? (j?.debug ? `${j.error} (${j.debug})` : j.error) : "Password change failed");
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg("Password updated.");
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setNext2("");
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Password change failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="security" className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Change Password</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
{needIdentifier && (
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Email or Username</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="you@example.com or your-handle"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Current Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">New Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">Confirm New Password</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="password"
|
||||
value={next2}
|
||||
onChange={(e) => setNext2(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Saving…" : "Update Password"}
|
||||
</button>
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
app/components/account/ProfileEditor.tsx
Normal file
211
app/components/account/ProfileEditor.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// components/account/ProfileEditor.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
username: string;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
location?: string | null;
|
||||
avatar?: { id: string; filename_download?: string } | null;
|
||||
};
|
||||
|
||||
export default function ProfileEditor({
|
||||
me: meProp,
|
||||
onUpdated,
|
||||
}: {
|
||||
me?: Me | null;
|
||||
onUpdated?: () => void;
|
||||
}) {
|
||||
const [me, setMe] = useState<Me | null>(meProp ?? null);
|
||||
const [first_name, setFirst] = useState("");
|
||||
const [last_name, setLast] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [profileLocation, setProfileLocation] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(!meProp);
|
||||
|
||||
const nextAccount = "/portal/account";
|
||||
|
||||
// Load profile if not provided via props
|
||||
useEffect(() => {
|
||||
if (meProp) {
|
||||
setMe(meProp);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const j = await r.json();
|
||||
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
if (!user) throw new Error("Bad response");
|
||||
if (alive) setMe(user);
|
||||
} catch (e: any) {
|
||||
if (alive) setMsg(`Failed to load: ${e?.message || e}`);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [meProp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
setFirst(me.first_name || "");
|
||||
setLast(me.last_name || "");
|
||||
setEmail(me.email || "");
|
||||
setProfileLocation(me.location || "");
|
||||
}, [me]);
|
||||
|
||||
// Auto-retry a pending sensitive update after coming back from reauth
|
||||
useEffect(() => {
|
||||
const raw = typeof window !== "undefined" ? sessionStorage.getItem("pendingProfileUpdate") : null;
|
||||
if (!raw) return;
|
||||
let pending: Record<string, any> | null = null;
|
||||
try {
|
||||
pending = JSON.parse(raw);
|
||||
} catch {
|
||||
pending = null;
|
||||
}
|
||||
if (!pending) {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pending),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update after re-auth failed");
|
||||
} else {
|
||||
setMsg("Saved after re-authentication.");
|
||||
onUpdated?.();
|
||||
}
|
||||
} finally {
|
||||
sessionStorage.removeItem("pendingProfileUpdate");
|
||||
}
|
||||
})();
|
||||
}, [onUpdated]);
|
||||
|
||||
const onSave = async () => {
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
first_name: first_name.trim(),
|
||||
last_name: last_name.trim(),
|
||||
email: email.trim() || null, // allow clearing email
|
||||
location: profileLocation.trim(),
|
||||
};
|
||||
const r = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (r.status === 428) {
|
||||
// Need reauth for sensitive change (email)
|
||||
if (typeof window !== "undefined") {
|
||||
// Stash the pending payload so we can retry after reauth
|
||||
sessionStorage.setItem("pendingProfileUpdate", JSON.stringify(payload));
|
||||
window.location.assign(
|
||||
`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount + "#security")}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent(nextAccount)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Update failed");
|
||||
return;
|
||||
}
|
||||
setMsg("Saved.");
|
||||
onUpdated?.();
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Update failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="rounded-md border p-4 text-sm opacity-70">Loading editor…</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Edit Profile</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">First Name</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={first_name}
|
||||
onChange={(e) => setFirst(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="opacity-60">Last Name</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={last_name}
|
||||
onChange={(e) => setLast(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Email (reauth required)</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1 sm:col-span-2">
|
||||
<span className="opacity-60">Location</span>
|
||||
<input
|
||||
className="rounded-md border px-2 py-1"
|
||||
value={profileLocation}
|
||||
onChange={(e) => setProfileLocation(e.target.value)}
|
||||
placeholder="City, Country"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
app/components/account/SupporterBadges.tsx
Normal file
111
app/components/account/SupporterBadges.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// components/account/SupporterBadges.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SupportBadge = {
|
||||
provider: "kofi" | "patreon" | "mighty" | string;
|
||||
active: boolean; // currently entitled (status==="active" && renews_at >= now)
|
||||
kind: "member" | "one_time" | "inactive";
|
||||
label: string; // e.g., "Ko-fi • Bronze" or "Ko-fi Supporter"
|
||||
tier?: string | null;
|
||||
renews_at?: string | null;
|
||||
started_at?: string | null;
|
||||
};
|
||||
|
||||
function providerIcon(p: string) {
|
||||
// Swap these for real icons later if you want
|
||||
if (p === "kofi") return "☕";
|
||||
if (p === "patreon") return "🅿️";
|
||||
if (p === "mighty") return "💬";
|
||||
return "🎖️";
|
||||
}
|
||||
|
||||
export default function SupporterBadges({
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const [badges, setBadges] = useState<SupportBadge[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let url = "/api/support/badges";
|
||||
const params = new URLSearchParams();
|
||||
if (email) params.set("email", String(email));
|
||||
if (userId) params.set("userId", String(userId));
|
||||
const qs = params.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||||
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
|
||||
.catch(async (e) => {
|
||||
try {
|
||||
const t = await e.text();
|
||||
setError(t || String(e));
|
||||
} catch {
|
||||
setError(String(e));
|
||||
}
|
||||
});
|
||||
}, [email, userId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-red-300/50 bg-red-50 p-3 text-sm text-red-800">
|
||||
Couldn’t load supporter badges.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!badges) {
|
||||
return (
|
||||
<div className="mt-4 animate-pulse rounded-lg border p-3 text-sm opacity-60">
|
||||
Loading supporter badges…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (badges.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border p-3 text-sm opacity-70">
|
||||
No supporter badges yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="text-sm font-medium opacity-80">Supporter Badges</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((b, i) => (
|
||||
<span
|
||||
key={`${b.provider}-${i}`}
|
||||
className={[
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm",
|
||||
b.active
|
||||
? "border-green-300 bg-green-50 text-green-900"
|
||||
: b.kind === "one_time"
|
||||
? "border-amber-300 bg-amber-50 text-amber-900"
|
||||
: "border-slate-300 bg-slate-50 text-slate-700",
|
||||
].join(" ")}
|
||||
title={
|
||||
b.active
|
||||
? b.renews_at
|
||||
? `Active • renews by ${new Date(b.renews_at).toLocaleDateString()}`
|
||||
: "Active"
|
||||
: b.kind === "one_time"
|
||||
? "One-time support"
|
||||
: "Inactive"
|
||||
}
|
||||
>
|
||||
<span>{providerIcon(b.provider)}</span>
|
||||
<span>{b.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
app/components/buying-guide/BuyingGuideList.tsx
Normal file
332
app/components/buying-guide/BuyingGuideList.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"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 | string;
|
||||
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]);
|
||||
|
||||
// Build a URL that sets ?product=<id> while preserving any existing params.
|
||||
const makeProductHref = (id: number | string) => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.set("product", String(id));
|
||||
return `?${sp.toString()}`;
|
||||
};
|
||||
|
||||
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={makeProductHref(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={makeProductHref(e.id)} className="text-accent hover:underline">
|
||||
{e.product_make} {e.product_model}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">What Is This?</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This Buying Guide helps you compare laser-related gear with hands-on reviews, scores, and recommendations.
|
||||
Use the filters and search to find what you’re looking for!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-8 border-border" />
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading entries...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No entries found.</p>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{filtered.map((entry) => {
|
||||
const filename = entry.index?.filename_disk;
|
||||
return (
|
||||
<div key={entry.id} className="entry-card">
|
||||
{filename ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${filename}`}
|
||||
alt={`${entry.product_make} ${entry.product_model}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="entry-image"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="entry-image bg-zinc-800 flex items-center justify-center text-zinc-400">No Image</div>
|
||||
)}
|
||||
<div className="entry-content">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground truncate-title">{entry.product_make}</p>
|
||||
<Link
|
||||
href={makeProductHref(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>
|
||||
);
|
||||
}
|
||||
188
app/components/buying-guide/BuyingGuideProduct.tsx
Normal file
188
app/components/buying-guide/BuyingGuideProduct.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal file
256
app/components/buying-guide/BuyingGuideProductClient.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_BASE_URL!;
|
||||
const ASSET_BASE =
|
||||
process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net";
|
||||
|
||||
type Score = { id: string | number; cat: string; value: number | string; body?: string };
|
||||
type LinkItem = { id?: string | number; text?: string; url: string; target?: string };
|
||||
|
||||
type Entry = {
|
||||
id: number | string;
|
||||
product_make?: string;
|
||||
product_model?: string;
|
||||
product_price?: string;
|
||||
review_overview_text?: string;
|
||||
review_intro_text?: string;
|
||||
author?: string;
|
||||
rec_text?: string;
|
||||
updates?: string;
|
||||
video_review_url?: string;
|
||||
header?: { id?: string; filename_disk?: string };
|
||||
date_updated?: string | number;
|
||||
links?: LinkItem[];
|
||||
scores?: Score[];
|
||||
};
|
||||
|
||||
export default function BuyingGuideProductClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const productId = searchParams.get("product");
|
||||
|
||||
const [entry, setEntry] = useState<Entry | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch the entry when productId changes
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
async function run() {
|
||||
if (!productId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/items/bg_entries/${productId}?fields=*,links.id,links.text,links.url,links.target,scores.id,scores.cat,scores.value,scores.body,header.id,header.filename_disk,date_updated`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { data } = await res.json();
|
||||
if (!abort) setEntry(data);
|
||||
} catch (e: any) {
|
||||
if (!abort) setError(e?.message || "Failed to load product");
|
||||
} finally {
|
||||
if (!abort) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [productId]);
|
||||
|
||||
const avgScore = useMemo(() => {
|
||||
if (!entry?.scores?.length) return "N/A";
|
||||
const sum = entry.scores.reduce((s, it) => s + Number(it.value ?? 0), 0);
|
||||
return (sum / entry.scores.length).toFixed(1);
|
||||
}, [entry]);
|
||||
|
||||
// Prefer the public filename_disk path (like the list view).
|
||||
const headerUrl =
|
||||
entry?.header?.filename_disk
|
||||
? `${ASSET_BASE}/assets/${entry.header.filename_disk}`
|
||||
: entry?.header?.id
|
||||
? `${ASSET_BASE}/assets/${entry.header.id}?cache-buster=${entry.date_updated}&key=system-large-contain`
|
||||
: null;
|
||||
|
||||
if (!productId) return null; // switcher guards this, but be defensive
|
||||
|
||||
if (loading) {
|
||||
return <div className="max-w-4xl mx-auto px-4 py-8">Loading…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<p className="text-red-500 text-sm">Error: {error}</p>
|
||||
<button
|
||||
className="text-blue-500 underline mt-2"
|
||||
onClick={() => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.delete("product");
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
}}
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entry) return 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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.delete("product");
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
}}
|
||||
className="text-sm text-blue-500 underline mt-2 inline-block"
|
||||
>
|
||||
← Back to Buying Guide
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
322
app/components/buying-guide/LaserFinderPanel.tsx
Normal file
322
app/components/buying-guide/LaserFinderPanel.tsx
Normal 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 we’ll suggest the best laser <em>types</em> for your work
|
||||
with clear use-cases, materials, and cautions. No product pitches—just guidance.
|
||||
</p>
|
||||
|
||||
{!result && (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Materials */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Materials (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["metals_bare", "Bare metals"],
|
||||
["metals_coated", "Coated/painted metals"],
|
||||
["plastics", "Plastics"],
|
||||
["wood_paper_leather", "Wood, paper, leather"],
|
||||
["glass_ceramic", "Glass / ceramic"],
|
||||
["stone", "Stone"],
|
||||
["textiles", "Textiles"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("materials")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedMaterials?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: choose at least one material for a better match.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Operations */}
|
||||
<fieldset className="border rounded p-4">
|
||||
<legend className="font-medium">Typical operations (select all that apply)</legend>
|
||||
<div className="grid sm:grid-cols-2 gap-2 mt-2">
|
||||
{[
|
||||
["deep_mark_metal", "Deep mark on metal"],
|
||||
["color_mark_stainless", "Color mark stainless"],
|
||||
["fine_engraving", "Fine engraving (small features)"],
|
||||
["photo_engrave", "Photo engraving"],
|
||||
["cut_nonmetals_thick", "Cut thick non-metals (e.g., 6+ mm acrylic/wood)"],
|
||||
["cut_nonmetals_thin", "Cut thin non-metals"],
|
||||
["mark_coated", "Mark coated items"],
|
||||
].map(([val, label]) => (
|
||||
<label key={val} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" value={val} {...register("operations")} /> {label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selectedOps?.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-2">Tip: pick one or more to sharpen the recommendation.</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Size / Detail / Speed / Budget */}
|
||||
<div className="grid sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Part size</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("part_size")}>
|
||||
<option value="small">Small (≤ 200 mm)</option>
|
||||
<option value="medium">Medium (≤ 600 mm)</option>
|
||||
<option value="large">Large (> 600 mm)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Detail</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("detail")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="micro">Micro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Throughput</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("throughput")}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Budget</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register("budget")}>
|
||||
<option value="low">Lower</option>
|
||||
<option value="mid">Mid</option>
|
||||
<option value="high">Higher</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90" type="submit">
|
||||
Get recommendations
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!!result && (
|
||||
<div className="space-y-6">
|
||||
<ResultCard
|
||||
title="Top recommendation"
|
||||
type={result.top[0]}
|
||||
why={result.why[result.top[0]]}
|
||||
/>
|
||||
|
||||
{result.top[1] && (
|
||||
<ResultCard
|
||||
title="Alternative to consider"
|
||||
type={result.top[1]}
|
||||
why={result.why[result.top[1]]}
|
||||
secondary
|
||||
/>
|
||||
)}
|
||||
|
||||
<CompareMatrix />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="px-3 py-2 border rounded hover:bg-muted" onClick={() => setResult(null)}>
|
||||
Start over
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-2 border rounded hover:bg-muted"
|
||||
onClick={() => { reset(); setResult(null); }}
|
||||
>
|
||||
New answers
|
||||
</button>
|
||||
<Link className="text-sm underline hover:no-underline" href="/buying-guide">
|
||||
Back to Buying Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({
|
||||
title,
|
||||
type,
|
||||
why,
|
||||
secondary,
|
||||
}: {
|
||||
title: string;
|
||||
type: LaserType;
|
||||
why?: string[];
|
||||
secondary?: boolean;
|
||||
}) {
|
||||
const info = TYPE_INFO[type];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{!secondary && <span className="text-xs rounded bg-muted px-2 py-0.5">Best match</span>}
|
||||
</div>
|
||||
<div className="text-base font-medium">{LASER_LABEL[type]}</div>
|
||||
<p className="text-sm text-muted-foreground">{info.summary}</p>
|
||||
|
||||
{!!why?.length && (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Why this fits</div>
|
||||
<ul className="list-disc pl-5 text-sm space-y-1">
|
||||
{why.slice(0, 5).map((w, i) => <li key={i}>{w}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 text-sm">
|
||||
<TagList label="Best for" items={info.bestFor} />
|
||||
<TagList label="Materials" items={info.materials} />
|
||||
<TagList label="Cautions" items={info.cautions} tone="warn" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Link className="text-sm underline hover:no-underline" href={info.learnLink}>
|
||||
See community settings
|
||||
</Link>
|
||||
<Link className="text-sm underline hover:no-underline" href="/submit/settings?target=settings_fiber">
|
||||
Suggest new settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({
|
||||
label,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
items: string[];
|
||||
tone?: "warn";
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">{label}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((t, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-xs px-2 py-1 rounded border ${
|
||||
tone === "warn" ? "bg-amber-50 border-amber-200" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompareMatrix() {
|
||||
const rows: Array<{
|
||||
k: LaserType;
|
||||
label: string;
|
||||
best: string[];
|
||||
ok: string[];
|
||||
avoid: string[];
|
||||
}> = [
|
||||
{
|
||||
k: "fiber",
|
||||
label: LASER_LABEL.fiber,
|
||||
best: ["Bare metals", "Deep metal engrave", "Color marking stainless"],
|
||||
ok: ["Some coated items", "Some plastics w/ additives"],
|
||||
avoid: ["Thick organics cutting"],
|
||||
},
|
||||
{
|
||||
k: "co2_gantry",
|
||||
label: LASER_LABEL.co2_gantry,
|
||||
best: ["Acrylic cutting", "Wood cutting/engraving", "Large panels"],
|
||||
ok: ["Leathers, textiles, rubber"],
|
||||
avoid: ["Bare metals (no coat)"],
|
||||
},
|
||||
{
|
||||
k: "co2_galvo",
|
||||
label: LASER_LABEL.co2_galvo,
|
||||
best: ["Fast marking organics", "Photo engraving organics"],
|
||||
ok: ["Coated metals/non-metals"],
|
||||
avoid: ["Thick sheet cutting", "Large panels"],
|
||||
},
|
||||
{
|
||||
k: "uv",
|
||||
label: LASER_LABEL.uv,
|
||||
best: ["Micro features", "Glass/ceramic/plastics marking"],
|
||||
ok: ["Fine logos on coated metals"],
|
||||
avoid: ["Thick cutting"],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded border p-4">
|
||||
<div className="text-sm font-medium mb-2">Compare laser types</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-2 pr-3">Type</th>
|
||||
<th className="py-2 pr-3">Best for</th>
|
||||
<th className="py-2 pr-3">Okay for</th>
|
||||
<th className="py-2">Not ideal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.k} className="border-b last:border-0 align-top">
|
||||
<td className="py-2 pr-3 font-medium">{r.label}</td>
|
||||
<td className="py-2 pr-3">{r.best.join(", ")}</td>
|
||||
<td className="py-2 pr-3">{r.ok.join(", ")}</td>
|
||||
<td className="py-2">{r.avoid.join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/components/buying-guide/dx.ts
Normal file
12
app/components/buying-guide/dx.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// components/utilities/buying-guide/dx.ts
|
||||
export type Q = Record<string, any>;
|
||||
|
||||
export async function dxGet<T>(path: string, query?: Q): Promise<T> {
|
||||
const qs = query ? "?" + new URLSearchParams(Object.entries(query).flatMap(([k, v]) =>
|
||||
Array.isArray(v) ? v.map(x => [k, String(x)]) : [[k, String(v)]]
|
||||
)).toString() : "";
|
||||
const res = await fetch(`/api/dx/${path}${qs}`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
return json?.data ?? json;
|
||||
}
|
||||
53
app/components/claims/ClaimButton.tsx
Normal file
53
app/components/claims/ClaimButton.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ClaimButton({
|
||||
collection,
|
||||
id,
|
||||
disabledReason,
|
||||
}: {
|
||||
collection: 'settings_fiber' | 'settings_uv' | 'settings_co2gal' | 'settings_co2gan' | 'projects' | string;
|
||||
id: string | number;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/claims', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_collection: collection, target_id: id }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) setMsg('✅ Claim submitted for review.');
|
||||
else setMsg(data?.message || '❌ Could not submit claim.');
|
||||
} catch {
|
||||
setMsg('❌ Network/auth error. Please sign in and try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !!disabledReason}
|
||||
className={`px-3 py-1 rounded-md text-sm border ${
|
||||
disabledReason
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'bg-accent text-background hover:opacity-90'
|
||||
}`}
|
||||
>
|
||||
{busy ? 'Submitting…' : 'Claim Ownership'}
|
||||
</button>
|
||||
{disabledReason ? <span className="text-xs text-muted-foreground">{disabledReason}</span> : null}
|
||||
{msg ? <span className="text-xs">{msg}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/components/common/OwnerBadge.tsx
Normal file
31
app/components/common/OwnerBadge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
export default function OwnerBadge({
|
||||
owner,
|
||||
uploader,
|
||||
className = '',
|
||||
}: {
|
||||
owner?: { id?: string | number; username?: string | null } | null;
|
||||
uploader?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasOwner = !!owner?.id;
|
||||
|
||||
// Prefer owner's username; fall back to uploader; ensure clean text
|
||||
const ownerName = (owner?.username ?? '').trim();
|
||||
const uploaderName = (uploader ?? '').trim();
|
||||
const name = ownerName || uploaderName || '—';
|
||||
|
||||
const label = hasOwner ? 'Owner' : 'Uploader';
|
||||
const title = hasOwner ? 'Owner' : (uploaderName ? 'Original uploader' : '');
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 text-xs px-2 py-1 rounded-md border border-border bg-card ${className}`}
|
||||
title={title}
|
||||
>
|
||||
<span className="opacity-70">{label}:</span>
|
||||
<span className="font-medium">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
530
app/components/details/CO2GalvoDetail.tsx
Normal file
530
app/components/details/CO2GalvoDetail.tsx
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
// components/details/CO2GalvoDetail.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import SettingsSubmit from "@/components/forms/SettingsSubmit";
|
||||
|
||||
type Rec = {
|
||||
submission_id: string | number;
|
||||
setting_title?: string | null;
|
||||
setting_notes?: string | null;
|
||||
|
||||
photo?: { id?: string } | string | null;
|
||||
screen?: { id?: string } | string | null;
|
||||
|
||||
// Material
|
||||
mat?: { id?: string | number; name?: string | null } | null;
|
||||
mat_coat?: { id?: string | number; name?: string | null } | null;
|
||||
mat_color?: { id?: string | number; name?: string | null } | null;
|
||||
mat_opacity?: { id?: string | number; opacity?: string | number | null } | null;
|
||||
mat_thickness?: number | null;
|
||||
|
||||
// Rig & Optics
|
||||
laser_soft?: { id?: string | number; name?: string | null } | string | number | null;
|
||||
source?: { submission_id?: string | number; make?: string | null; model?: string | null; nm?: string | null } | null;
|
||||
lens?: { id?: string | number; field_size?: string | number | null; focal_length?: string | number | null } | null;
|
||||
focus?: number | null;
|
||||
|
||||
// CO₂ Galvo fixed lists
|
||||
lens_conf?: { id?: string | number; name?: string | null } | null;
|
||||
lens_apt?: { id?: string | number; name?: string | null } | string | number | null;
|
||||
lens_exp?: { id?: string | number; name?: string | null } | string | number | null;
|
||||
|
||||
repeat_all?: number | null;
|
||||
|
||||
// Repeaters
|
||||
fill_settings?: any[] | null;
|
||||
line_settings?: any[] | null;
|
||||
raster_settings?: any[] | null;
|
||||
|
||||
owner?: { id?: string | number; username?: string | null } | string | number | null;
|
||||
uploader?: string | null;
|
||||
|
||||
last_modified_date?: string | null;
|
||||
};
|
||||
|
||||
export default function CO2GalvoDetail({ id, editable }: { id: string | number; editable?: boolean }) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const editMode = sp.get("edit") === "1";
|
||||
|
||||
const [rec, setRec] = useState<Rec | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// me id for owner-only edit
|
||||
const [meId, setMeId] = useState<string | null>(null);
|
||||
|
||||
// Lightbox
|
||||
const [viewerSrc, setViewerSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewerSrc(null);
|
||||
if (viewerSrc) window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [viewerSrc]);
|
||||
|
||||
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const fileUrl = (assetId?: string) => (assetId ? (API_BASE ? `${API_BASE}/assets/${assetId}` : `/api/dx/assets/${assetId}`) : "");
|
||||
|
||||
function ownerLabel(o: Rec["owner"]) {
|
||||
if (!o) return "—";
|
||||
if (typeof o === "string" || typeof o === "number") return String(o);
|
||||
return o.username || String(o.id ?? "—");
|
||||
}
|
||||
const yesNo = (v: any) => (v ? "Yes" : "No");
|
||||
|
||||
// stringify possibly-object options for display
|
||||
const optLabel = (v: any): string => {
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "string" || typeof v === "number") return String(v);
|
||||
if (typeof v === "object") return v.name ?? (v.id != null ? String(v.id) : "—");
|
||||
return "—";
|
||||
};
|
||||
|
||||
// numeric helper (treat booleans/objects as null)
|
||||
const asNumOrNull = (v: any): number | null => {
|
||||
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === "string" && v.trim() !== "") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// fetch me id
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
|
||||
const t = await r.text();
|
||||
const j = t ? JSON.parse(t) : null;
|
||||
const idVal = j?.data?.id ?? j?.id ?? null;
|
||||
if (alive) setMeId(idVal ? String(idVal) : null);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let dead = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
const fields = [
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"setting_notes",
|
||||
"photo.id",
|
||||
"screen.id",
|
||||
|
||||
// Material
|
||||
"mat.id",
|
||||
"mat.name",
|
||||
"mat_coat.id",
|
||||
"mat_coat.name",
|
||||
"mat_color.id",
|
||||
"mat_color.name",
|
||||
"mat_opacity.id",
|
||||
"mat_opacity.opacity",
|
||||
"mat_thickness",
|
||||
|
||||
// Rig & Optics
|
||||
"laser_soft.id",
|
||||
"laser_soft.name",
|
||||
"source.submission_id",
|
||||
"source.make",
|
||||
"source.model",
|
||||
"source.nm",
|
||||
"lens.id",
|
||||
"lens.field_size",
|
||||
"lens.focal_length",
|
||||
"lens_conf.id",
|
||||
"lens_conf.name",
|
||||
"lens_apt.id",
|
||||
"lens_apt.name",
|
||||
"lens_exp.id",
|
||||
"lens_exp.name",
|
||||
"focus",
|
||||
"repeat_all",
|
||||
|
||||
// Repeaters
|
||||
"fill_settings",
|
||||
"line_settings",
|
||||
"raster_settings",
|
||||
|
||||
// Meta
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
"uploader",
|
||||
"last_modified_date",
|
||||
].join(",");
|
||||
|
||||
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&filter[submission_id][_eq]=${encodeURIComponent(
|
||||
String(id)
|
||||
)}&limit=1`;
|
||||
|
||||
const r = await fetch(url, { cache: "no-store", credentials: "include" });
|
||||
const text = await r.text();
|
||||
const j = text ? JSON.parse(text) : null;
|
||||
if (!r.ok) throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
||||
const row: Rec | null = Array.isArray(j?.data) ? j.data[0] || null : null;
|
||||
if (!row) throw new Error("Setting not found.");
|
||||
if (!dead) setRec(row);
|
||||
} catch (e: any) {
|
||||
if (!dead) setErr(e?.message || String(e));
|
||||
} finally {
|
||||
if (!dead) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <p className="p-6">Loading setting…</p>;
|
||||
if (err)
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">{err}</div>
|
||||
</div>
|
||||
);
|
||||
if (!rec) return <p className="p-6">Setting not found.</p>;
|
||||
|
||||
const photoId = typeof rec.photo === "object" ? rec.photo?.id : (rec.photo as any);
|
||||
const screenId = typeof rec.screen === "object" ? rec.screen?.id : (rec.screen as any);
|
||||
const photoSrc = fileUrl(photoId ? String(photoId) : "");
|
||||
const screenSrc = fileUrl(screenId ? String(screenId) : "");
|
||||
|
||||
const softName = typeof rec.laser_soft === "object" ? rec.laser_soft?.name ?? "—" : "—";
|
||||
const sourceText =
|
||||
[rec.source?.make, rec.source?.model].filter(Boolean).join(" ") + (rec.source?.nm ? ` (${rec.source.nm})` : "");
|
||||
|
||||
const ownerId =
|
||||
typeof rec.owner === "object" ? (rec.owner?.id != null ? String(rec.owner.id) : null) : rec.owner != null ? String(rec.owner) : null;
|
||||
|
||||
const isMine = meId && ownerId ? meId === ownerId : false;
|
||||
|
||||
// Small field renderer (label on top, value below)
|
||||
const Field = ({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode | string | number | null | undefined;
|
||||
suffix?: string;
|
||||
}) => {
|
||||
const primitive = typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
const isEmpty = value == null || value === "" || (typeof value === "number" && isNaN(value as number));
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="text-sm break-words">
|
||||
{isEmpty ? (
|
||||
"—"
|
||||
) : primitive ? (
|
||||
<>
|
||||
{String(value)}
|
||||
{suffix ? <span className="opacity-70"> {suffix}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("edit", "1");
|
||||
router.replace(`?${q.toString()}`, { scroll: false });
|
||||
};
|
||||
const closeEdit = () => {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.delete("edit");
|
||||
router.replace(`?${q.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
// Pretty labels
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
uni: "UniDirectional",
|
||||
bi: "BiDirectional",
|
||||
offset: "Offset Fill",
|
||||
};
|
||||
const DITHER_LABEL = (v: string | undefined) => (v ? v.charAt(0).toUpperCase() + v.slice(1) : "—");
|
||||
|
||||
// ----- EDIT MODE -----
|
||||
if (editMode && rec) {
|
||||
const toId = (v: any) => (v == null ? "" : typeof v === "object" ? (v.id ?? v.submission_id ?? "") : String(v));
|
||||
|
||||
const initialValues = {
|
||||
submission_id: rec.submission_id,
|
||||
setting_title: rec.setting_title ?? "",
|
||||
setting_notes: rec.setting_notes ?? "",
|
||||
photo: photoId ? String(photoId) : null,
|
||||
screen: screenId ? String(screenId) : null,
|
||||
// Material
|
||||
mat: toId(rec.mat) || "",
|
||||
mat_coat: toId(rec.mat_coat) || "",
|
||||
mat_color: toId(rec.mat_color) || "",
|
||||
mat_opacity: toId(rec.mat_opacity) || "",
|
||||
mat_thickness: rec.mat_thickness ?? null,
|
||||
// Rig & Optics
|
||||
laser_soft: typeof rec.laser_soft === "object" ? String(rec.laser_soft?.id ?? "") : String(rec.laser_soft ?? "") || "",
|
||||
source:
|
||||
rec.source && typeof rec.source === "object" ? String(rec.source.submission_id ?? "") : String(rec.source ?? "") || "",
|
||||
lens: toId(rec.lens) || "",
|
||||
focus: rec.focus ?? null,
|
||||
// CO2 triplet
|
||||
lens_conf: toId(rec.lens_conf) || "",
|
||||
lens_apt: toId(rec.lens_apt) || "",
|
||||
lens_exp: toId(rec.lens_exp) || "",
|
||||
repeat_all: rec.repeat_all ?? null,
|
||||
// Repeaters
|
||||
fill_settings: rec.fill_settings ?? [],
|
||||
line_settings: rec.line_settings ?? [],
|
||||
raster_settings: rec.raster_settings ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl lg:text-2xl font-semibold">Edit CO₂ Galvo Setting</h1>
|
||||
<button className="px-2 py-1 border rounded" onClick={closeEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<SettingsSubmit mode="edit" submissionId={rec.submission_id} initialValues={initialValues} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold break-words">{rec.setting_title || "Untitled"}</h1>
|
||||
{editable && isMine ? (
|
||||
<button className="px-2 py-1 border rounded text-sm" onClick={openEdit}>
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Last modified: {rec.last_modified_date || "—"}</div>
|
||||
</header>
|
||||
|
||||
{/* Top row: Info (left) + Images (right) */}
|
||||
<section className="grid md:grid-cols-2 gap-6 items-start">
|
||||
{/* Info */}
|
||||
<div className="grid gap-3">
|
||||
<Field label="Owner" value={ownerLabel(rec.owner)} />
|
||||
<Field label="Uploader" value={rec.uploader || "—"} />
|
||||
{rec.setting_notes ? <Field label="Notes" value={<p className="whitespace-pre-wrap">{rec.setting_notes}</p>} /> : null}
|
||||
</div>
|
||||
|
||||
{/* Images (side-by-side thumbnails) */}
|
||||
{(photoSrc || screenSrc) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{photoSrc ? (
|
||||
<figure className="space-y-1 justify-self-start">
|
||||
<div
|
||||
className="border rounded overflow-hidden cursor-zoom-in mx-auto w-40 md:w-48"
|
||||
style={{ aspectRatio: "1 / 1" }}
|
||||
onClick={() => setViewerSrc(photoSrc)}
|
||||
>
|
||||
<img src={photoSrc} alt="Result" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<figcaption className="text-xs text-muted-foreground text-center">Result</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
{screenSrc ? (
|
||||
<figure className="space-y-1 justify-self-start">
|
||||
<div
|
||||
className="border rounded overflow-hidden cursor-zoom-in mx-auto w-40 md:w-48"
|
||||
style={{ aspectRatio: "1 / 1" }}
|
||||
onClick={() => setViewerSrc(screenSrc)}
|
||||
>
|
||||
<img src={screenSrc} alt="Settings Screenshot" className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
<figcaption className="text-xs text-muted-foreground text-center">Settings Screenshot</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Two columns below: left Rig & Optics, right Material */}
|
||||
<section className="grid md:grid-cols-2 gap-6">
|
||||
{/* Rig & Optics */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Rig & Optics</h2>
|
||||
<div className="grid gap-3">
|
||||
<Field label="Software" value={softName} />
|
||||
<Field label="Laser Source" value={sourceText || "—"} />
|
||||
<Field label="Lens Configuration" value={rec.lens_conf?.name || "—"} />
|
||||
<Field label="Scan Head Aperture" value={optLabel(rec.lens_apt)} suffix="mm" />
|
||||
<Field label="Beam Expander" value={optLabel(rec.lens_exp)} suffix="x" />
|
||||
<Field
|
||||
label="Scan Lens"
|
||||
value={rec.lens ? `${rec.lens.field_size ?? "—"}${rec.lens.focal_length ? ` / ${rec.lens.focal_length}` : ""}` : "—"}
|
||||
/>
|
||||
<Field label="Focus" value={rec.focus ?? "—"} suffix="mm" />
|
||||
<Field label="Repeat All" value={rec.repeat_all ?? "—"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Material */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Material</h2>
|
||||
<div className="grid gap-3">
|
||||
<Field label="Material" value={rec.mat?.name || "—"} />
|
||||
<Field label="Coating" value={rec.mat_coat?.name || "—"} />
|
||||
<Field label="Color" value={rec.mat_color?.name || "—"} />
|
||||
<Field label="Opacity" value={rec.mat_opacity?.opacity ?? "—"} />
|
||||
<Field label="Thickness" value={rec.mat_thickness ?? "—"} suffix="mm" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repeaters (cards, full width) */}
|
||||
{(rec.fill_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Fill Settings</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{rec.fill_settings!.map((r: any, i: number) => {
|
||||
const showIncrement = !!r.auto;
|
||||
return (
|
||||
<div key={i} className="border rounded p-3 space-y-2">
|
||||
<div className="font-medium">{r.name || `Fill ${i + 1}`}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
<Field label="Type" value={TYPE_LABEL[r.type] || "—"} />
|
||||
<Field label="Power" value={r.power ?? "—"} suffix="%" />
|
||||
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
|
||||
<Field label="Interval" value={r.interval ?? "—"} suffix="mm" />
|
||||
<Field label="Angle" value={r.angle ?? "—"} suffix="°" />
|
||||
<Field label="Passes" value={r.pass ?? "—"} />
|
||||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||||
{/* Pulse removed for CO2 */}
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-2 items-center">
|
||||
<Field label="Auto Rotate" value={yesNo(r.auto)} />
|
||||
{showIncrement && <Field label="Auto Rotate Increment" value={r.increment ?? "—"} suffix="°" />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Field label="Crosshatch" value={yesNo(r.cross)} />
|
||||
<Field label="Flood Fill" value={yesNo(r.flood)} />
|
||||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(rec.line_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Line Settings</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{rec.line_settings!.map((r: any, i: number) => {
|
||||
const perfEnabled = !!r.perf;
|
||||
const wobbleEnabled = !!r.wobble;
|
||||
const cutVal = asNumOrNull(r.cut ?? r.perf_cut ?? r.cut_length);
|
||||
const skipVal = asNumOrNull(r.skip ?? r.perf_skip ?? r.skip_length);
|
||||
const stepVal = asNumOrNull(r.step);
|
||||
const sizeVal = asNumOrNull(r.size);
|
||||
|
||||
return (
|
||||
<div key={i} className="border rounded p-3 space-y-2">
|
||||
<div className="font-medium">{r.name || `Line ${i + 1}`}</div>
|
||||
|
||||
{/* Base fields – Pulse removed for CO2 */}
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||||
<Field label="Power" value={r.power ?? "—"} suffix="%" />
|
||||
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
|
||||
<Field label="Passes" value={r.pass ?? "—"} />
|
||||
</div>
|
||||
|
||||
{/* Perforation row */}
|
||||
<div className="grid sm:grid-cols-3 gap-2 items-center">
|
||||
<Field label="Perforation Mode" value={yesNo(perfEnabled)} />
|
||||
{perfEnabled && <Field label="Cut" value={cutVal ?? "—"} suffix="mm" />}
|
||||
{perfEnabled && <Field label="Skip" value={skipVal ?? "—"} suffix="mm" />}
|
||||
</div>
|
||||
|
||||
{/* Wobble row */}
|
||||
<div className="grid sm:grid-cols-3 gap-2 items-center">
|
||||
<Field label="Wobble" value={yesNo(wobbleEnabled)} />
|
||||
{wobbleEnabled && <Field label="Step" value={stepVal ?? "—"} suffix="mm" />}
|
||||
{wobbleEnabled && <Field label="Size" value={sizeVal ?? "—"} suffix="mm" />}
|
||||
</div>
|
||||
|
||||
{/* Simple toggle */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(rec.raster_settings?.length ?? 0) > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Raster Settings</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{rec.raster_settings!.map((r: any, i: number) => {
|
||||
const isHalftone = r?.dither === "halftone";
|
||||
return (
|
||||
<div key={i} className="border rounded p-3 space-y-2">
|
||||
<div className="font-medium">{r.name || `Raster ${i + 1}`}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
<Field label="Type" value={TYPE_LABEL[r.type] || "—"} />
|
||||
<Field label="Dither" value={DITHER_LABEL(r.dither)} />
|
||||
<Field label="Power" value={r.power ?? "—"} suffix="%" />
|
||||
<Field label="Speed" value={r.speed ?? "—"} suffix="mm/s" />
|
||||
<Field label="Interval" value={r.interval ?? "—"} suffix="mm" />
|
||||
<Field label="Passes" value={r.pass ?? "—"} />
|
||||
<Field label="Frequency" value={r.frequency ?? "—"} suffix="kHz" />
|
||||
{/* Pulse removed for CO2 */}
|
||||
{isHalftone && <Field label="Halftone Cell" value={r.halftone_cell ?? "—"} />}
|
||||
{isHalftone && <Field label="Halftone Angle" value={r.halftone_angle ?? "—"} />}
|
||||
<Field label="Dot Width Adjustment" value={r.dot ?? "—"} suffix="mm" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Field label="Crosshatch" value={yesNo(r.cross)} />
|
||||
<Field label="Inverted" value={yesNo(r.inversion)} />
|
||||
<Field label="Air Assist" value={yesNo(r.air)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
{viewerSrc && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 p-4 flex items-center justify-center" onClick={() => setViewerSrc(null)}>
|
||||
<img src={viewerSrc} alt="" className="max-w-full max-h-full cursor-zoom-out" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
677
app/components/forms/SettingsSubmit.tsx
Normal file
677
app/components/forms/SettingsSubmit.tsx
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
// components/forms/SettingsSubmit.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm, useFieldArray, useWatch, type UseFormRegister } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Target = "settings_co2gal";
|
||||
|
||||
type Opt = { id: string; label: string };
|
||||
type Me = { id: string; username?: string; email?: string } | null;
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
|
||||
type EditInitialValues = {
|
||||
submission_id: string | number;
|
||||
|
||||
setting_title?: string;
|
||||
setting_notes?: string;
|
||||
|
||||
photo?: string | null;
|
||||
screen?: string | null;
|
||||
|
||||
// Material
|
||||
mat?: string | null;
|
||||
mat_coat?: string | null;
|
||||
mat_color?: string | null;
|
||||
mat_opacity?: string | null;
|
||||
mat_thickness?: number | null;
|
||||
|
||||
// Rig & Optics
|
||||
laser_soft?: string | null;
|
||||
source?: string | null; // submission_id
|
||||
lens?: string | null;
|
||||
focus?: number | null;
|
||||
|
||||
// CO2 Galvo triplet
|
||||
lens_conf?: string | null;
|
||||
lens_apt?: string | null;
|
||||
lens_exp?: string | null;
|
||||
|
||||
repeat_all?: number | null;
|
||||
|
||||
// Repeaters
|
||||
fill_settings?: any[] | null;
|
||||
line_settings?: any[] | null;
|
||||
raster_settings?: any[] | null;
|
||||
};
|
||||
|
||||
type BaseProps = { mode?: "create" | "edit"; submissionId?: string | number; initialValues?: EditInitialValues | null };
|
||||
export default function SettingsSubmit({ mode = "create", submissionId, initialValues }: BaseProps) {
|
||||
const router = useRouter();
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
const [me, setMe] = useState<Me>(null);
|
||||
const [submitErr, setSubmitErr] = useState<string | null>(null);
|
||||
|
||||
// Robust current-user fetch
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r1 = await fetch(`/api/me`, { cache: "no-store", credentials: "include" });
|
||||
if (r1.ok) {
|
||||
const j = await r1.json().catch(() => null);
|
||||
if (alive && j) {
|
||||
const id = j?.id ?? j?.data?.id ?? null;
|
||||
const username = j?.username ?? j?.data?.username ?? j?.name ?? null;
|
||||
const email = j?.email ?? j?.data?.email ?? null;
|
||||
setMe(id ? { id, username: username ?? undefined, email: email ?? undefined } : null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const r2 = await fetch(`/api/dx/users/me?fields=id,username,email`, { cache: "no-store", credentials: "include" });
|
||||
if (alive && r2.ok) {
|
||||
const j2 = await r2.json().catch(() => null);
|
||||
const d = j2?.data ?? j2 ?? null;
|
||||
setMe(d?.id ? { id: d.id, username: d.username ?? undefined, email: d.email ?? undefined } : null);
|
||||
}
|
||||
} catch {
|
||||
if (alive) setMe(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Options loaders (Directus reads)
|
||||
function useOptions(path: string, includeId?: string | null) {
|
||||
const [opts, setOpts] = useState<Opt[]>([]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
|
||||
(async () => {
|
||||
let url = "";
|
||||
let map = (rows: any[]) =>
|
||||
rows.map((r) => ({ id: String(r.id ?? r.submission_id), label: String(r.name ?? r.model ?? r.opacity ?? r.id) }));
|
||||
|
||||
if (path === "material") url = `${API}/items/material?fields=id,name&limit=1000&sort=name`;
|
||||
else if (path === "material_coating") url = `${API}/items/material_coating?fields=id,name&limit=1000&sort=name`;
|
||||
else if (path === "material_color") url = `${API}/items/material_color?fields=id,name&limit=1000&sort=name`;
|
||||
else if (path === "material_opacity") {
|
||||
url = `${API}/items/material_opacity?fields=id,opacity&limit=1000&sort=opacity`;
|
||||
map = (rows) => rows.map((r) => ({ id: String(r.id), label: String(r.opacity ?? r.id) }));
|
||||
} else if (path === "laser_software") {
|
||||
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
|
||||
} else if (path === "laser_source_co2_galvo") {
|
||||
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
|
||||
type Row = { submission_id?: string | number; make?: string; model?: string; nm?: string | number | null };
|
||||
const toNum = (v: unknown): number | null => {
|
||||
if (typeof v === "number") return globalThis.Number.isFinite(v) ? v : null;
|
||||
if (typeof v === "string") {
|
||||
const m = v.match(/-?\d+(\.\d+)?/);
|
||||
const n = m ? globalThis.Number(m[0]) : NaN;
|
||||
return globalThis.Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
map = (rows: Row[]) =>
|
||||
rows
|
||||
.filter((r) => {
|
||||
const nmVal = toNum(r.nm);
|
||||
return nmVal !== null && nmVal >= 10000 && nmVal <= 11000;
|
||||
})
|
||||
.map((r) => ({
|
||||
id: String(r.submission_id ?? ""),
|
||||
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id ?? ""),
|
||||
}));
|
||||
} else if (path === "laser_scan_lens") {
|
||||
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
|
||||
map = (rows) =>
|
||||
rows
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(parseFloat(a.focal_length ?? "99999") || 99999) - (parseFloat(b.focal_length ?? "99999") || 99999)
|
||||
)
|
||||
.map((r) => {
|
||||
const fs = r.field_size ? `${r.field_size} mm` : "";
|
||||
const fl = r.focal_length ? `${r.focal_length} mm` : "";
|
||||
const label = [fs, fl].filter(Boolean).join(" — ") || String(r.id);
|
||||
return { id: String(r.id), label };
|
||||
});
|
||||
} else if (path === "laser_scan_lens_config") {
|
||||
url = `${API}/items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name`;
|
||||
} else if (path === "laser_scan_lens_apt") {
|
||||
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
|
||||
} else if (path === "laser_scan_lens_exp") {
|
||||
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
|
||||
} else {
|
||||
setOpts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { cache: "no-store", credentials: "include" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
const rows = Array.isArray(j?.data) ? j.data : [];
|
||||
let list = map(rows);
|
||||
|
||||
if (includeId && !list.some((o) => String(o.id) === String(includeId))) {
|
||||
list = [{ id: String(includeId), label: "(current selection)" }, ...list];
|
||||
}
|
||||
if (live) setOpts(list);
|
||||
})().catch(() => live && setOpts([]));
|
||||
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, includeId]);
|
||||
|
||||
return { opts };
|
||||
}
|
||||
|
||||
// Enumerations
|
||||
const FILL_TYPES: Opt[] = [
|
||||
{ id: "uni", label: "UniDirectional" },
|
||||
{ id: "bi", label: "BiDirectional" },
|
||||
{ id: "offset", label: "Offset Fill" },
|
||||
];
|
||||
const RASTER_TYPES = FILL_TYPES;
|
||||
const RASTER_DITHER: Opt[] = [
|
||||
"threshold",
|
||||
"ordered",
|
||||
"atkinson",
|
||||
"dither",
|
||||
"stucki",
|
||||
"jarvis",
|
||||
"newsprint",
|
||||
"halftone",
|
||||
"sketch",
|
||||
"grayscale",
|
||||
].map((x) => ({ id: x, label: x[0].toUpperCase() + x.slice(1) }));
|
||||
|
||||
// react-hook-form
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<any>({
|
||||
defaultValues: {
|
||||
setting_title: "",
|
||||
setting_notes: "",
|
||||
// Material
|
||||
mat: "",
|
||||
mat_coat: "",
|
||||
mat_color: "",
|
||||
mat_opacity: "",
|
||||
mat_thickness: "",
|
||||
// Rig & Optics
|
||||
laser_soft: "",
|
||||
source: "",
|
||||
// keep these blank so Select shows "—"
|
||||
lens_conf: "",
|
||||
lens_apt: "",
|
||||
lens_exp: "",
|
||||
lens: "",
|
||||
focus: "",
|
||||
repeat_all: "",
|
||||
// Repeaters
|
||||
fill_settings: [],
|
||||
line_settings: [],
|
||||
raster_settings: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Repeaters
|
||||
const fills = useFieldArray({ control, name: "fill_settings" });
|
||||
const lines = useFieldArray({ control, name: "line_settings" });
|
||||
const rasters = useFieldArray({ control, name: "raster_settings" });
|
||||
|
||||
// Option lists (include current IDs to guarantee a visible option)
|
||||
const mats = useOptions("material", initialValues?.mat ?? null);
|
||||
const coats = useOptions("material_coating", initialValues?.mat_coat ?? null);
|
||||
const colors = useOptions("material_color", initialValues?.mat_color ?? null);
|
||||
const opacs = useOptions("material_opacity", initialValues?.mat_opacity ?? null);
|
||||
const soft = useOptions("laser_software", initialValues?.laser_soft ?? null);
|
||||
const srcs = useOptions("laser_source_co2_galvo", initialValues?.source ?? null);
|
||||
const lens = useOptions("laser_scan_lens", initialValues?.lens ?? null);
|
||||
const conf = useOptions("laser_scan_lens_config", initialValues?.lens_conf ?? null);
|
||||
const apt = useOptions("laser_scan_lens_apt", initialValues?.lens_apt ?? null);
|
||||
const exp = useOptions("laser_scan_lens_exp", initialValues?.lens_exp ?? null);
|
||||
|
||||
// Prefill (edit)
|
||||
useEffect(() => {
|
||||
if (!isEdit || !initialValues) return;
|
||||
reset({
|
||||
setting_title: initialValues.setting_title ?? "",
|
||||
setting_notes: initialValues.setting_notes ?? "",
|
||||
// Material
|
||||
mat: initialValues.mat ?? "",
|
||||
mat_coat: initialValues.mat_coat ?? "",
|
||||
mat_color: initialValues.mat_color ?? "",
|
||||
mat_opacity: initialValues.mat_opacity ?? "",
|
||||
mat_thickness: initialValues.mat_thickness ?? "",
|
||||
// Rig & Optics
|
||||
laser_soft: initialValues.laser_soft ?? "",
|
||||
source: initialValues.source ?? "",
|
||||
lens_conf: initialValues.lens_conf ?? "",
|
||||
lens_apt: initialValues.lens_apt ?? "",
|
||||
lens_exp: initialValues.lens_exp ?? "",
|
||||
lens: initialValues.lens ?? "",
|
||||
focus: initialValues.focus ?? "",
|
||||
repeat_all: initialValues.repeat_all ?? "",
|
||||
// Repeaters
|
||||
fill_settings: initialValues.fill_settings ?? [],
|
||||
line_settings: initialValues.line_settings ?? [],
|
||||
raster_settings: initialValues.raster_settings ?? [],
|
||||
});
|
||||
}, [isEdit, initialValues, reset]);
|
||||
|
||||
// Re-apply select values when options hydrate (forces DOM to adopt prefilled values)
|
||||
useEffect(() => {
|
||||
if (!isEdit || !initialValues) return;
|
||||
|
||||
const hydrate = (name: string, currentId: string | null | undefined, opts: Opt[]) => {
|
||||
if (!currentId || !opts.length) return;
|
||||
setValue(name as any, String(currentId), { shouldDirty: false, shouldValidate: false });
|
||||
};
|
||||
|
||||
hydrate("mat", initialValues.mat, mats.opts);
|
||||
hydrate("mat_coat", initialValues.mat_coat, coats.opts);
|
||||
hydrate("mat_color", initialValues.mat_color, colors.opts);
|
||||
hydrate("mat_opacity", initialValues.mat_opacity, opacs.opts);
|
||||
|
||||
hydrate("laser_soft", initialValues.laser_soft, soft.opts);
|
||||
hydrate("source", initialValues.source, srcs.opts);
|
||||
|
||||
hydrate("lens_conf", initialValues.lens_conf, conf.opts);
|
||||
hydrate("lens_apt", initialValues.lens_apt, apt.opts);
|
||||
hydrate("lens_exp", initialValues.lens_exp, exp.opts);
|
||||
|
||||
hydrate("lens", initialValues.lens, lens.opts);
|
||||
}, [
|
||||
isEdit,
|
||||
initialValues,
|
||||
mats.opts,
|
||||
coats.opts,
|
||||
colors.opts,
|
||||
opacs.opts,
|
||||
soft.opts,
|
||||
srcs.opts,
|
||||
conf.opts,
|
||||
apt.opts,
|
||||
exp.opts,
|
||||
lens.opts,
|
||||
setValue,
|
||||
]);
|
||||
|
||||
// Image files
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [screenFile, setScreenFile] = useState<File | null>(null);
|
||||
const onPick = (setter: (f: File | null) => void) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setter(e.target.files?.[0] ?? null);
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
setSubmitErr(null);
|
||||
const payload: any = {
|
||||
target: "settings_co2gal" as Target,
|
||||
...(isEdit ? { mode: "edit" as const, submission_id: submissionId } : {}),
|
||||
setting_title: values.setting_title,
|
||||
setting_notes: values.setting_notes || "",
|
||||
// Material
|
||||
mat: values.mat || null,
|
||||
mat_coat: values.mat_coat || null,
|
||||
mat_color: values.mat_color || null,
|
||||
mat_opacity: values.mat_opacity || null,
|
||||
mat_thickness: values.mat_thickness === "" ? null : globalThis.Number(values.mat_thickness),
|
||||
// Rig & Optics
|
||||
laser_soft: values.laser_soft || null,
|
||||
source: values.source || null,
|
||||
lens_conf: values.lens_conf || null,
|
||||
lens_apt: values.lens_apt || null,
|
||||
lens_exp: values.lens_exp || null,
|
||||
lens: values.lens || null,
|
||||
focus: values.focus === "" ? null : globalThis.Number(values.focus),
|
||||
repeat_all: values.repeat_all === "" ? null : globalThis.Number(values.repeat_all),
|
||||
// Repeaters (raw pass-through; api will normalize nums/bools)
|
||||
fill_settings: values.fill_settings || [],
|
||||
line_settings: values.line_settings || [],
|
||||
raster_settings: values.raster_settings || [],
|
||||
// If editing with existing asset IDs, the API will accept them
|
||||
...(initialValues?.photo ? { photo: initialValues.photo } : {}),
|
||||
...(initialValues?.screen ? { screen: initialValues.screen } : {}),
|
||||
};
|
||||
|
||||
const form = new FormData();
|
||||
form.set("payload", JSON.stringify(payload));
|
||||
if (photoFile) form.set("photo", photoFile, photoFile.name || "photo");
|
||||
if (screenFile) form.set("screen", screenFile, screenFile.name || "screen");
|
||||
|
||||
const res = await fetch("/api/settings", { method: "POST", body: form, credentials: "include" });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setSubmitErr(j?.error || `Submit failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
router.back();
|
||||
} else {
|
||||
reset();
|
||||
setPhotoFile(null);
|
||||
setScreenFile(null);
|
||||
router.push(`/submit/settings/success?id=${encodeURIComponent(String(j?.id ?? ""))}`);
|
||||
}
|
||||
};
|
||||
|
||||
const meLabel = me?.username || me?.email || "";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
{/* Hide local H1 in edit mode to avoid duplicate page title */}
|
||||
{!isEdit && <h1 className="text-xl font-semibold">Submit CO₂ Galvo Setting</h1>}
|
||||
{meLabel ? <p className="text-sm text-muted-foreground">Submitting as {meLabel}</p> : null}
|
||||
{submitErr ? (
|
||||
<div className="border border-red-500 bg-red-50 text-red-700 rounded px-3 py-2 text-sm">{submitErr}</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Info */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Info</h2>
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Title</label>
|
||||
<input className="w-full border rounded px-2 py-1" {...register("setting_title", { required: true })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Notes</label>
|
||||
<textarea rows={4} className="w-full border rounded px-2 py-1" {...register("setting_notes")} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Images */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Images</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Result Photo</label>
|
||||
<input type="file" accept="image/*" onChange={onPick(setPhotoFile)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Settings Screenshot (optional)</label>
|
||||
<input type="file" accept="image/*" onChange={onPick(setScreenFile)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Material */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Material</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Select label="Material" {...{ name: "mat", register, options: mats.opts, required: true }} />
|
||||
<Select label="Coating" {...{ name: "mat_coat", register, options: coats.opts, required: true }} />
|
||||
<Select label="Color" {...{ name: "mat_color", register, options: colors.opts, required: true }} />
|
||||
<Select label="Opacity" {...{ name: "mat_opacity", register, options: opacs.opts, required: true }} />
|
||||
<Number label="Material Thickness (mm)" name="mat_thickness" register={register} step="0.01" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Rig & Optics (order per spec) */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Rig & Optics</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Select label="Laser Software" {...{ name: "laser_soft", register, options: soft.opts, required: true }} />
|
||||
<Select label="Laser Source" {...{ name: "source", register, options: srcs.opts, required: true }} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Select label="Lens Configuration" {...{ name: "lens_conf", register, options: conf.opts, required: true }} />
|
||||
<Select label="Scan Head Aperture (mm)" {...{ name: "lens_apt", register, options: apt.opts, required: true }} />
|
||||
<Select label="Beam Expander (X Magnification)" {...{ name: "lens_exp", register, options: exp.opts, required: true }} />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Select label="Scan Lens" {...{ name: "lens", register, options: lens.opts, required: true }} />
|
||||
<Number label="Focus (mm)" name="focus" register={register} step="1" />
|
||||
<Number label="Repeat All" name="repeat_all" register={register} step="1" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Process Settings */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Process Settings</h2>
|
||||
|
||||
{/* Fill */}
|
||||
<Repeater
|
||||
title="Fill"
|
||||
fields={fills.fields}
|
||||
onAdd={() => fills.append({ type: "" })}
|
||||
onRemove={(i) => fills.remove(i)}
|
||||
render={(i) => {
|
||||
const autoRotate = !!useWatch({ control, name: `fill_settings.${i}.auto` });
|
||||
return (
|
||||
<>
|
||||
{/* Base fields */}
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<Text label="Name" name={`fill_settings.${i}.name`} register={register} />
|
||||
<Select label="Type" name={`fill_settings.${i}.type`} register={register} options={FILL_TYPES} />
|
||||
<Number label="Frequency (kHz)" name={`fill_settings.${i}.frequency`} register={register} step="0.1" />
|
||||
{/* Pulse removed for CO2 */}
|
||||
<Number label="Power (%)" name={`fill_settings.${i}.power`} register={register} step="0.1" />
|
||||
<Number label="Speed (mm/s)" name={`fill_settings.${i}.speed`} register={register} step="0.1" />
|
||||
<Number label="Interval (mm)" name={`fill_settings.${i}.interval`} register={register} step="0.001" />
|
||||
<Number label="Passes" name={`fill_settings.${i}.pass`} register={register} step="1" />
|
||||
<Number label="Angle (°)" name={`fill_settings.${i}.angle`} register={register} step="1" />
|
||||
</div>
|
||||
|
||||
{/* Auto rotate row */}
|
||||
<div className="grid md:grid-cols-2 gap-3 items-center">
|
||||
<Check label="Auto Rotate" name={`fill_settings.${i}.auto`} register={register} />
|
||||
{autoRotate && (
|
||||
<Number
|
||||
label="Auto Rotate Increment (°)"
|
||||
name={`fill_settings.${i}.increment`}
|
||||
register={register}
|
||||
step="0.001"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simple toggles row */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Check label="Crosshatch" name={`fill_settings.${i}.cross`} register={register} />
|
||||
<Check label="Flood Fill" name={`fill_settings.${i}.flood`} register={register} />
|
||||
<Check label="Air Assist" name={`fill_settings.${i}.air`} register={register} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Line */}
|
||||
<Repeater
|
||||
title="Line"
|
||||
fields={lines.fields}
|
||||
onAdd={() => lines.append({})}
|
||||
onRemove={(i) => lines.remove(i)}
|
||||
render={(i) => {
|
||||
const perf = !!useWatch({ control, name: `line_settings.${i}.perf` });
|
||||
const wobble = !!useWatch({ control, name: `line_settings.${i}.wobble` });
|
||||
return (
|
||||
<>
|
||||
{/* Base fields */}
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<Text label="Name" name={`line_settings.${i}.name`} register={register} />
|
||||
<Number label="Frequency (kHz)" name={`line_settings.${i}.frequency`} register={register} step="0.1" />
|
||||
{/* Pulse removed for CO2 */}
|
||||
<Number label="Power (%)" name={`line_settings.${i}.power`} register={register} step="0.1" />
|
||||
<Number label="Speed (mm/s)" name={`line_settings.${i}.speed`} register={register} step="0.1" />
|
||||
<Number label="Passes" name={`line_settings.${i}.pass`} register={register} step="1" />
|
||||
</div>
|
||||
|
||||
{/* Perforation row */}
|
||||
<div className="grid md:grid-cols-3 gap-3 items-center">
|
||||
<Check label="Perforation Mode" name={`line_settings.${i}.perf`} register={register} />
|
||||
{perf && <Number label="Cut (mm)" name={`line_settings.${i}.cut`} register={register} step="0.001" />}
|
||||
{perf && <Number label="Skip (mm)" name={`line_settings.${i}.skip`} register={register} step="0.001" />}
|
||||
</div>
|
||||
|
||||
{/* Wobble row */}
|
||||
<div className="grid md:grid-cols-3 gap-3 items-center">
|
||||
<Check label="Wobble" name={`line_settings.${i}.wobble`} register={register} />
|
||||
{wobble && <Number label="Step (mm)" name={`line_settings.${i}.step`} register={register} step="0.001" />}
|
||||
{wobble && <Number label="Size (mm)" name={`line_settings.${i}.size`} register={register} step="0.001" />}
|
||||
</div>
|
||||
|
||||
{/* Simple toggle */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Check label="Air Assist" name={`line_settings.${i}.air`} register={register} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Raster */}
|
||||
<Repeater
|
||||
title="Raster"
|
||||
fields={rasters.fields}
|
||||
onAdd={() => rasters.append({ type: "", dither: "" })}
|
||||
onRemove={(i) => rasters.remove(i)}
|
||||
render={(i) => {
|
||||
const ditherVal = useWatch({ control, name: `raster_settings.${i}.dither` }) || "";
|
||||
const isHalftone = ditherVal === "halftone";
|
||||
return (
|
||||
<>
|
||||
{/* Base fields */}
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<Text label="Name" name={`raster_settings.${i}.name`} register={register} />
|
||||
<Number label="Frequency (kHz)" name={`raster_settings.${i}.frequency`} register={register} step="0.1" />
|
||||
{/* Pulse removed for CO2 */}
|
||||
<Select label="Type" name={`raster_settings.${i}.type`} register={register} options={RASTER_TYPES} />
|
||||
<Select label="Dither" name={`raster_settings.${i}.dither`} register={register} options={RASTER_DITHER} />
|
||||
<Number label="Power (%)" name={`raster_settings.${i}.power`} register={register} step="0.1" />
|
||||
<Number label="Speed (mm/s)" name={`raster_settings.${i}.speed`} register={register} step="0.1" />
|
||||
<Number label="Interval (mm)" name={`raster_settings.${i}.interval`} register={register} step="0.001" />
|
||||
{/* allow two decimals */}
|
||||
<Number label="Dot Width Adjustment (mm)" name={`raster_settings.${i}.dot`} register={register} step="0.01" />
|
||||
<Number label="Passes" name={`raster_settings.${i}.pass`} register={register} step="1" />
|
||||
</div>
|
||||
|
||||
{/* Halftone row */}
|
||||
{isHalftone && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Number label="Halftone Cell" name={`raster_settings.${i}.halftone_cell`} register={register} step="1" />
|
||||
<Number label="Halftone Angle" name={`raster_settings.${i}.halftone_angle`} register={register} step="1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simple toggles row */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<Check label="Crosshatch" name={`raster_settings.${i}.cross`} register={register} />
|
||||
<Check label="Inverted" name={`raster_settings.${i}.inversion`} register={register} />
|
||||
<Check label="Air Assist" name={`raster_settings.${i}.air`} register={register} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<button disabled={isSubmitting} className="px-3 py-2 border rounded bg-accent text-background disabled:opacity-50">
|
||||
{isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Small UI */
|
||||
function Select({
|
||||
label,
|
||||
name,
|
||||
register,
|
||||
options,
|
||||
required,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
register: UseFormRegister<any>;
|
||||
options: Opt[];
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
{label} {required ? <span className="text-red-600">*</span> : null}
|
||||
</label>
|
||||
<select className="w-full border rounded px-2 py-1" {...register(name, { required })}>
|
||||
<option value="">—</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Number({ label, name, register, step }: any) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm mb-1">{label}</label>
|
||||
<input type="number" step={step ?? "1"} className="w-full border rounded px-2 py-1" {...register(name)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Text({ label, name, register }: any) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm mb-1">{label}</label>
|
||||
<input className="w-full border rounded px-2 py-1" {...register(name)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Check({ label, name, register }: any) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...register(name)} /> {label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
function Repeater({ title, fields, onAdd, onRemove, render }: any) {
|
||||
return (
|
||||
<fieldset className="border rounded p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="font-semibold">{title}</legend>
|
||||
<button type="button" className="px-2 py-1 border rounded" onClick={onAdd}>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((_: any, i: number) => (
|
||||
<div key={i} className="rounded-lg border bg-muted/20 p-3 space-y-3">
|
||||
{render(i)}
|
||||
<div className="pt-1">
|
||||
<button type="button" className="px-2 py-1 border rounded" onClick={() => onRemove(i)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
206
app/components/lists/CO2GalvoList.tsx
Normal file
206
app/components/lists/CO2GalvoList.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// components/lists/CO2GalvoList.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Owner =
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
id?: string | number;
|
||||
username?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type Row = {
|
||||
submission_id: string | number;
|
||||
setting_title?: string | null;
|
||||
owner?: Owner;
|
||||
uploader?: string | null;
|
||||
mat?: { name?: string | null } | null;
|
||||
mat_coat?: { name?: string | null } | null;
|
||||
source?: { model?: string | null } | null;
|
||||
lens?: { field_size?: string | number | null } | null;
|
||||
};
|
||||
|
||||
async function readJson(r: Response) {
|
||||
const t = await r.text();
|
||||
try {
|
||||
return t ? JSON.parse(t) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CO2GalvoList({
|
||||
linkFor,
|
||||
queryText,
|
||||
onQueryChange,
|
||||
}: {
|
||||
linkFor: (id: string | number) => string;
|
||||
queryText?: string;
|
||||
onQueryChange?: (q: string) => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [localQuery, setLocalQuery] = useState(queryText ?? "");
|
||||
const [meId, setMeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryText !== undefined) setLocalQuery(queryText);
|
||||
}, [queryText]);
|
||||
|
||||
// who am I?
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/dx/users/me?fields=id`, { cache: "no-store", credentials: "include" });
|
||||
const j = await readJson(r);
|
||||
const idVal = j?.data?.id ?? j?.id ?? null;
|
||||
if (live) setMeId(idVal ? String(idVal) : null);
|
||||
} catch {
|
||||
if (live) setMeId(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
const fields = [
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
"owner.first_name",
|
||||
"owner.last_name",
|
||||
"owner.email",
|
||||
"uploader",
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
].join(",");
|
||||
// Use the same proxy as Details so relation expansion & auth match
|
||||
const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`;
|
||||
const r = await fetch(url, { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) {
|
||||
const j = await readJson(r);
|
||||
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
||||
}
|
||||
const j = await r.json();
|
||||
const list = Array.isArray(j?.data) ? j.data : [];
|
||||
if (live) setRows(list);
|
||||
})()
|
||||
.catch(() => live && setRows([]))
|
||||
.finally(() => live && setLoading(false));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ownerLabel = (o: Owner) => {
|
||||
if (!o) return "—";
|
||||
if (typeof o === "string" || typeof o === "number") return String(o);
|
||||
return (
|
||||
o.username ||
|
||||
[o.first_name, o.last_name].filter(Boolean).join(" ").trim() ||
|
||||
o.email ||
|
||||
(o.id != null ? String(o.id) : "—")
|
||||
);
|
||||
};
|
||||
|
||||
const isMine = (o: Owner): boolean => {
|
||||
if (!meId || !o) return false;
|
||||
if (typeof o === "string" || typeof o === "number") return String(o) === meId;
|
||||
if (o.id != null) return String(o.id) === meId;
|
||||
return false;
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = (localQuery || "").toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) =>
|
||||
[r.setting_title, ownerLabel(r.owner), r.uploader, r.mat?.name, r.mat_coat?.name, r.source?.model, r.lens?.field_size]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q))
|
||||
);
|
||||
}, [rows, localQuery]);
|
||||
|
||||
const addEditParam = (href: string) => (href.includes("?") ? `${href}&edit=1` : `${href}?edit=1`);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={localQuery}
|
||||
onChange={(e) => {
|
||||
setLocalQuery(e.currentTarget.value);
|
||||
onQueryChange?.(e.currentTarget.value);
|
||||
}}
|
||||
placeholder="Search title, owner, material, model…"
|
||||
className="w-full border rounded px-3 py-2"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p>Loading…</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left w-[28%]">Title</th>
|
||||
<th className="px-2 py-2 text-left w-[16%]">Owner</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Material</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Coating</th>
|
||||
<th className="px-2 py-2 text-left w-[14%]">Model</th>
|
||||
<th className="px-2 py-2 text-left w-[10%]">Field</th>
|
||||
<th className="px-2 py-2 text-left w-[4%]">Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filtered.map((r) => {
|
||||
const mine = isMine(r.owner);
|
||||
const ownerText = ownerLabel(r.owner) + (mine ? " (you)" : "");
|
||||
const viewHref = linkFor(r.submission_id);
|
||||
const editHref = addEditParam(viewHref);
|
||||
return (
|
||||
<tr key={r.submission_id} className="hover:bg-muted/40">
|
||||
<td className="px-2 py-2 truncate">
|
||||
<Link href={viewHref} className="underline">
|
||||
{r.setting_title || "Untitled"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 truncate">{ownerText}</td>
|
||||
<td className="px-2 py-2 truncate">{r.mat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{r.mat_coat?.name || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{r.source?.model || "—"}</td>
|
||||
<td className="px-2 py-2 truncate">{r.lens?.field_size || "—"}</td>
|
||||
<td className="px-2 py-2">
|
||||
{mine ? (
|
||||
<Link href={editHref} className="underline">
|
||||
Edit
|
||||
</Link>
|
||||
) : (
|
||||
<span className="opacity-50">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
app/components/portal/BuyingGuideSwitcher.tsx
Normal file
71
app/components/portal/BuyingGuideSwitcher.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BuyingGuideList = dynamic(
|
||||
() => import("@/components/buying-guide/BuyingGuideList"),
|
||||
{ ssr: false }
|
||||
);
|
||||
const LaserFinderPanel = dynamic(
|
||||
() => import("@/components/buying-guide/LaserFinderPanel"),
|
||||
{ ssr: false }
|
||||
);
|
||||
const BuyingGuideProductClient = dynamic(
|
||||
() => import("@/components/buying-guide/BuyingGuideProductClient"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const TABS = [
|
||||
{ key: "list", label: "Buying Guide" },
|
||||
{ key: "finder", label: "Laser Finder" },
|
||||
];
|
||||
|
||||
export default function BuyingGuideSwitcher() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const active = useMemo(() => searchParams.get("tab") || "list", [searchParams]);
|
||||
const productId = searchParams.get("product");
|
||||
|
||||
const setTab = (key: string) => {
|
||||
const sp = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
sp.set("tab", key);
|
||||
// When changing tabs, ensure product detail (if open) is cleared
|
||||
sp.delete("product");
|
||||
router.push(`?${sp.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="inline-flex rounded-md border overflow-hidden">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm transition-colors",
|
||||
active === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="rounded-md border p-4">
|
||||
{productId ? (
|
||||
<BuyingGuideProductClient />
|
||||
) : active === "finder" ? (
|
||||
<LaserFinderPanel />
|
||||
) : (
|
||||
<BuyingGuideList />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
app/components/portal/LaserToolkitSwitcher.tsx
Normal file
58
app/components/portal/LaserToolkitSwitcher.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// components/portal/LaserToolkitSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TOOLKIT_TABS } from "@/components/utilities/laser-toolkit/registry";
|
||||
|
||||
export default function LaserToolkitSwitcher() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const activeKey = useMemo(() => {
|
||||
const def = TOOLKIT_TABS[0]?.key ?? "beam-spot-size";
|
||||
const t = (sp.get("lt") || def).toLowerCase();
|
||||
return TOOLKIT_TABS.some(x => x.key === t) ? t : def;
|
||||
}, [sp]);
|
||||
|
||||
const active = useMemo(
|
||||
() => TOOLKIT_TABS.find(x => x.key === activeKey) ?? TOOLKIT_TABS[0],
|
||||
[activeKey]
|
||||
);
|
||||
|
||||
function setTab(k: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("lt", k);
|
||||
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return <div className="text-sm text-zinc-400">No tools registered.</div>;
|
||||
}
|
||||
|
||||
const ActiveCmp = active.component;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TOOLKIT_TABS.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm",
|
||||
activeKey === t.key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<ActiveCmp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/components/portal/MaterialsSwitcher.tsx
Normal file
59
app/components/portal/MaterialsSwitcher.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// components/portal/MaterialsSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Reuse the *existing* canonical pages.
|
||||
// Adjust paths if your folders differ.
|
||||
const MaterialsPanel = dynamic(() => import("@/app/materials/materials/page"), { ssr: false });
|
||||
const CoatingsPanel = dynamic(() => import("@/app/materials/materials-coatings/page"), { ssr: false });
|
||||
|
||||
const TABS = [
|
||||
{ key: "materials", label: "Materials" },
|
||||
{ key: "materials-coatings", label: "Coatings" },
|
||||
];
|
||||
|
||||
function Panel({ tab }: { tab: string }) {
|
||||
switch (tab) {
|
||||
case "materials": return <MaterialsPanel />;
|
||||
case "materials-coatings": return <CoatingsPanel />;
|
||||
default: return <MaterialsPanel />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function MaterialsSwitcher() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const active = sp.get("t") || "materials";
|
||||
|
||||
function setTab(nextKey: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
router.replace(`/portal/materials?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition",
|
||||
active === key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<Panel tab={active} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
app/components/portal/ProjectsSwitcher.tsx
Normal file
74
app/components/portal/ProjectsSwitcher.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// components/portal/ProjectsSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Canonical viewer page; use dynamic in a client component
|
||||
const ProjectsView = dynamic(() => import("@/app/projects/page"), { ssr: false });
|
||||
|
||||
const TABS = [
|
||||
{ key: "list", label: "Projects" },
|
||||
{ key: "add", label: "Add Project" },
|
||||
];
|
||||
|
||||
function Panel({ tab }: { tab: string }) {
|
||||
switch (tab) {
|
||||
case "list":
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<ProjectsView />
|
||||
</div>
|
||||
);
|
||||
case "add":
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Add Project</h3>
|
||||
<div className="text-sm opacity-70">
|
||||
Project submission form will be embedded here. We’ll wire to an authenticated endpoint (e.g. <code>POST /api/my/projects</code>) and auto-assign owner.
|
||||
</div>
|
||||
<ul className="text-sm list-disc pl-5 opacity-70">
|
||||
<li>Fields: name (required), description, attachments, etc.</li>
|
||||
<li>Server derives <code>owner</code> from bearer (<code>/users/me</code>)</li>
|
||||
<li>On success: toast + navigate back to list</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProjectsSwitcher() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const active = sp.get("t") || "list";
|
||||
|
||||
function setTab(nextKey: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
router.replace(`/portal/projects?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition",
|
||||
active === key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Panel tab={active} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
app/components/portal/RigsSwitcher.tsx
Normal file
68
app/components/portal/RigsSwitcher.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// components/portal/RigsSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import RigsListClient from "@/app/rigs/RigsListClient";
|
||||
import RigBuilderClient from "@/app/rigs/RigBuilderClient";
|
||||
|
||||
type Opt = { id: string | number; label: string };
|
||||
|
||||
const TABS = [
|
||||
{ key: "my", label: "My Rigs" },
|
||||
{ key: "add", label: "Add Rig" },
|
||||
];
|
||||
|
||||
function Panel({ tab, rigTypes }: { tab: string; rigTypes: Opt[] }) {
|
||||
switch (tab) {
|
||||
case "my":
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<RigsListClient />
|
||||
</div>
|
||||
);
|
||||
case "add":
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<RigBuilderClient rigTypes={rigTypes} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RigsSwitcher({ rigTypes }: { rigTypes: Opt[] }) {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const active = sp.get("t") || "my";
|
||||
|
||||
function setTab(nextKey: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
router.replace(`/portal/rigs?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition",
|
||||
active === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Panel tab={active} rigTypes={rigTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/components/portal/SettingsSwitcher.tsx
Normal file
127
app/components/portal/SettingsSwitcher.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// components/portal/SettingsSwitcher.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Existing canonical pages (Fiber/UV/Gantry still use their pages for now)
|
||||
const FiberPanel = dynamic(() => import("@/app/settings/fiber/page"), { ssr: false });
|
||||
const UVPanel = dynamic(() => import("@/app/settings/uv/page"), { ssr: false });
|
||||
const CO2GalvoPanel = dynamic(() => import("@/components/portal/panels/CO2GalvoPanel"), { ssr: false });
|
||||
const CO2GantryPanel = dynamic(() => import("@/app/settings/co2-gantry/page"), { ssr: false });
|
||||
|
||||
// NEW: embed the submission form in the "Add" tab
|
||||
const SettingsSubmit = dynamic(
|
||||
() => import("@/components/forms/SettingsSubmit"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type DataTab = "fiber" | "uv" | "co2-gantry" | "co2-galvo";
|
||||
type Tab = DataTab | "add" | "my";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "fiber", label: "Fiber" },
|
||||
{ key: "uv", label: "UV" },
|
||||
{ key: "co2-gantry", label: "CO₂ Gantry" },
|
||||
{ key: "co2-galvo", label: "CO₂ Galvo" },
|
||||
{ key: "my", label: "My Settings" }, // ← NEW
|
||||
{ key: "add", label: "Add Setting" },
|
||||
];
|
||||
|
||||
const isDataTab = (k: string): k is DataTab =>
|
||||
k === "fiber" || k === "uv" || k === "co2-gantry" || k === "co2-galvo";
|
||||
|
||||
const tabToTarget: Record<DataTab,
|
||||
"settings_fiber" | "settings_uv" | "settings_co2gan" | "settings_co2gal"
|
||||
> = {
|
||||
fiber: "settings_fiber",
|
||||
uv: "settings_uv",
|
||||
"co2-gantry": "settings_co2gan",
|
||||
"co2-galvo": "settings_co2gal",
|
||||
};
|
||||
|
||||
function Panel({ tab, lastDataTab }: { tab: Tab; lastDataTab: DataTab }) {
|
||||
switch (tab) {
|
||||
case "fiber": return <FiberPanel />;
|
||||
case "uv": return <UVPanel />;
|
||||
case "co2-galvo": return <CO2GalvoPanel />;
|
||||
case "co2-gantry": return <CO2GantryPanel />;
|
||||
case "add":
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Add Setting</h3>
|
||||
<SettingsSubmit mode="create" />
|
||||
</div>
|
||||
);
|
||||
case "my":
|
||||
// We navigate away for "My Settings", so this branch is not normally rendered.
|
||||
// Fallback content (just in case someone forces ?t=my on this page):
|
||||
return (
|
||||
<div className="text-sm">
|
||||
Opening <span className="font-medium">My Settings</span>…
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <FiberPanel />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsSwitcher() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const activeRaw = (sp.get("t") || "fiber").toLowerCase();
|
||||
const active: Tab = (TABS.some(t => t.key === activeRaw) ? activeRaw : "fiber") as Tab;
|
||||
|
||||
// last data tab is taken from ?last=… (or fallback to current active if it’s a data tab)
|
||||
const lastParam = (sp.get("last") || (isDataTab(active) ? active : "fiber")).toLowerCase();
|
||||
const lastDataTab: DataTab = isDataTab(lastParam) ? (lastParam as DataTab) : "fiber";
|
||||
|
||||
function setTab(nextKey: Tab) {
|
||||
// NEW: "My Settings" navigates to its own page
|
||||
if (nextKey === "my") {
|
||||
router.push("/portal/my-settings");
|
||||
return;
|
||||
}
|
||||
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
|
||||
// Clear detail-related params when switching tabs to avoid stale state
|
||||
q.delete("view");
|
||||
q.delete("id");
|
||||
q.delete("edit");
|
||||
|
||||
// keep track of last data tab so the Add tab knows which target to preselect
|
||||
if (nextKey === "add") {
|
||||
q.set("last", isDataTab(active) ? (active as DataTab) : lastDataTab);
|
||||
} else if (isDataTab(nextKey)) {
|
||||
q.set("last", nextKey);
|
||||
}
|
||||
|
||||
router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{TABS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition",
|
||||
active === key ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No extra border/padding wrapper → avoids double-framing */}
|
||||
<Panel tab={active} lastDataTab={lastDataTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
app/components/portal/UtilitySwitcher.tsx
Normal file
275
app/components/portal/UtilitySwitcher.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"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";
|
||||
|
||||
type Item = {
|
||||
key: string; // used in ?t=
|
||||
label: string;
|
||||
note?: string;
|
||||
icon?: string; // optional icon (public/images/utils/<icon>)
|
||||
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[] = [
|
||||
{
|
||||
key: "laser-toolkit",
|
||||
label: "Laser Toolkit",
|
||||
note: "convert laser settings, interval and more",
|
||||
icon: "toolkit.png",
|
||||
component: LaserToolkitSwitcher,
|
||||
href: "/laser-toolkit",
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
label: "File Server",
|
||||
note: "download from our file explorer",
|
||||
icon: "fs.png",
|
||||
component: FileBrowserPanel,
|
||||
href: "/files",
|
||||
},
|
||||
{
|
||||
key: "svgnest",
|
||||
label: "SVGnest",
|
||||
note: "automatically nests parts and exports svg",
|
||||
icon: "nest.png",
|
||||
component: SVGNestPanel,
|
||||
href: "/svgnest",
|
||||
},
|
||||
{
|
||||
key: "background-remover",
|
||||
label: "BG Remover",
|
||||
note: "open source background remover",
|
||||
icon: "bgrm.png",
|
||||
component: BackgroundRemoverPanel,
|
||||
href: "/background-remover",
|
||||
},
|
||||
|
||||
// These stay on makearmy (external services)
|
||||
{
|
||||
key: "picsur",
|
||||
label: "Picsur",
|
||||
note: "Simple Image Host",
|
||||
icon: "picsur.png",
|
||||
href: "https://images.makearmy.io",
|
||||
},
|
||||
{
|
||||
key: "privatebin",
|
||||
label: "PrivateBin",
|
||||
note: "Encrypted internet clipboard",
|
||||
icon: "privatebin.png",
|
||||
href: "https://paste.makearmy.io/",
|
||||
},
|
||||
{
|
||||
key: "forgejo",
|
||||
label: "Forgejo",
|
||||
note: "git for our community members",
|
||||
icon: "forge.png",
|
||||
href: "https://forge.makearmy.io",
|
||||
},
|
||||
];
|
||||
|
||||
function isAbsoluteUrl(href: string) {
|
||||
return /^https?:\/\//i.test(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* External if it's an absolute URL and NOT same-origin as the current site.
|
||||
* Relative paths are always internal.
|
||||
*/
|
||||
function isExternalHref(href?: string) {
|
||||
if (!href) return false;
|
||||
if (!isAbsoluteUrl(href)) return false;
|
||||
|
||||
try {
|
||||
const u = new URL(href);
|
||||
if (typeof window === "undefined") return true;
|
||||
return u.origin !== window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If href is absolute AND same-origin, convert it to a site-relative path.
|
||||
* Otherwise return as-is.
|
||||
*/
|
||||
function toSameOriginPath(href: string) {
|
||||
if (!isAbsoluteUrl(href)) return href;
|
||||
|
||||
try {
|
||||
const u = new URL(href);
|
||||
if (typeof window === "undefined") return href;
|
||||
if (u.origin === window.location.origin) {
|
||||
return `${u.pathname}${u.search}${u.hash}`;
|
||||
}
|
||||
} catch {}
|
||||
return href;
|
||||
}
|
||||
|
||||
function Panel({ item }: { item: Item }) {
|
||||
if (item.component) {
|
||||
const Cmp = item.component;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Removed notes/headers to keep UI clean */}
|
||||
<Cmp embedded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const href = item.href || "/";
|
||||
const external = isExternalHref(href);
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Open {item.label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = toSameOriginPath(href);
|
||||
return (
|
||||
<iframe
|
||||
key={src}
|
||||
src={src}
|
||||
className="w-full"
|
||||
style={{ height: "72vh" }}
|
||||
// no sandbox; needs drag/drop etc.
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UtilitySwitcher() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const openedRef = useRef<string | null>(null);
|
||||
const [firstPaint, setFirstPaint] = useState(true);
|
||||
|
||||
const activeKey = useMemo(() => {
|
||||
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
|
||||
return ITEMS.some((i) => i.key === t) ? t : ITEMS[0].key;
|
||||
}, [sp]);
|
||||
|
||||
const activeItem = useMemo(
|
||||
() => ITEMS.find((i) => i.key === activeKey) || ITEMS[0],
|
||||
[activeKey]
|
||||
);
|
||||
|
||||
function setTab(nextKey: string) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", nextKey);
|
||||
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auto-open behavior for external tools when selected via URL (?t=...).
|
||||
* RECOMMENDED: keep this off to avoid popup blockers / surprise tabs.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const item = activeItem;
|
||||
if (!item?.href) return;
|
||||
if (item.component) return;
|
||||
if (!isExternalHref(item.href)) return;
|
||||
|
||||
if (openedRef.current === item.key) return;
|
||||
openedRef.current = item.key;
|
||||
|
||||
const AUTO_OPEN_ON_FIRST_PAINT = false;
|
||||
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
||||
window.open(item.href, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeItem?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstPaint(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* top buttons unchanged */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{ITEMS.map((it) => {
|
||||
const isInline = Boolean(it.component);
|
||||
const external = !isInline && isExternalHref(it.href);
|
||||
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
||||
const isActive = it.key === activeKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={it.key}
|
||||
onClick={() => {
|
||||
setTab(it.key);
|
||||
if (!isInline && external) {
|
||||
window.open(it.href!, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
title={it.note || it.label}
|
||||
>
|
||||
{iconSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm border object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<span className="truncate">{it.label}</span>
|
||||
|
||||
{!isInline && external && (
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
new tab
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ⛔️ removed the old border/padding frame here */}
|
||||
<Panel item={activeItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
app/components/portal/panels/CO2GalvoPanel.tsx
Normal file
85
app/components/portal/panels/CO2GalvoPanel.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import CO2GalvoList from "@/components/lists/CO2GalvoList";
|
||||
import CO2GalvoDetail from "@/components/details/CO2GalvoDetail";
|
||||
|
||||
export default function CO2GalvoPanel() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const id = sp.get("id");
|
||||
const view = sp.get("view") === "detail" && id ? "detail" : "list";
|
||||
|
||||
function setView(next: "list" | "detail", nextId?: string | number) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", "co2-galvo");
|
||||
if (next === "detail" && nextId != null) {
|
||||
q.set("view", "detail");
|
||||
q.set("id", String(nextId));
|
||||
} else {
|
||||
q.set("view", "list");
|
||||
q.delete("id");
|
||||
q.delete("edit");
|
||||
}
|
||||
router.replace(`/portal/laser-settings?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
const linkFor = (sid: string | number, opts?: { edit?: boolean }) => {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
q.set("t", "co2-galvo");
|
||||
q.set("view", "detail");
|
||||
q.set("id", String(sid));
|
||||
if (opts?.edit) q.set("edit", "1"); else q.delete("edit");
|
||||
return `/portal/laser-settings?${q.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* App-style header (no big boxes) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1.5 rounded border ${view === "list" ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => id && setView("detail", id)}
|
||||
disabled={!id}
|
||||
className={`px-3 py-1.5 rounded border ${view === "detail" ? "bg-primary text-primary-foreground" : "hover:bg-muted"} disabled:opacity-50`}
|
||||
>
|
||||
Detail
|
||||
</button>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
CO₂ Galvo Settings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{view === "list" ? (
|
||||
<div className="w-full">
|
||||
<CO2GalvoList linkFor={linkFor} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{id ? (
|
||||
<>
|
||||
<CO2GalvoDetail id={id} editable />
|
||||
<div className="mt-2">
|
||||
<button
|
||||
className="px-2 py-1 border rounded text-sm"
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No record selected.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
app/components/toolkit/ToolShell.tsx
Normal file
49
app/components/toolkit/ToolShell.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToolShellProps = {
|
||||
title: string;
|
||||
/** Preferred prop */
|
||||
description?: string;
|
||||
/** Back-compat alias used by some pages */
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
};
|
||||
|
||||
export default function ToolShell({
|
||||
title,
|
||||
description,
|
||||
subtitle,
|
||||
className,
|
||||
children,
|
||||
backHref = "/laser-toolkit",
|
||||
backLabel = "Back to Toolkit",
|
||||
}: ToolShellProps) {
|
||||
const desc = description ?? subtitle;
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-5xl px-4 py-6", className)}>
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{desc ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{desc}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={backHref}
|
||||
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{backLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
58
app/components/ui/accordion.tsx
Normal file
58
app/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
36
app/components/ui/badge.tsx
Normal file
36
app/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
app/components/ui/button.tsx
Normal file
56
app/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
app/components/ui/card.tsx
Normal file
79
app/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
app/components/ui/input.tsx
Normal file
28
app/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type = "text", ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm",
|
||||
"shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
||||
24
app/components/ui/label.tsx
Normal file
24
app/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// /var/www/makearmy.io/app/app/components/ui/label.tsx
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export default Label;
|
||||
|
||||
160
app/components/ui/select.tsx
Normal file
160
app/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
15
app/components/ui/skeleton.tsx
Normal file
15
app/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
117
app/components/ui/table.tsx
Normal file
117
app/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
22
app/components/ui/textarea.tsx
Normal file
22
app/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
129
app/components/ui/toast.tsx
Normal file
129
app/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
app/components/ui/toaster.tsx
Normal file
35
app/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
591
app/components/utilities/BackgroundRemoverPanel.tsx
Normal file
591
app/components/utilities/BackgroundRemoverPanel.tsx
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
// ---------- Preview + batch helpers ----------
|
||||
const PREVIEW_MAX = 2048; // max long-edge for on-screen previews
|
||||
const BATCH_SIZES = [2048, 1536, 1280, 1024, 864, 720]; // adaptive preview long-edges
|
||||
const COOLDOWN_MS = 150; // tiny cooldown between requests to ease VRAM
|
||||
|
||||
async function makePreview(blob: Blob, maxEdge = PREVIEW_MAX): Promise<Blob> {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const { width, height } = bitmap;
|
||||
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
||||
const outW = Math.max(1, Math.round(width * scale));
|
||||
const outH = Math.max(1, Math.round(height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(bitmap, 0, 0, outW, outH);
|
||||
bitmap.close();
|
||||
const outBlob = await new Promise<Blob>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), "image/png")
|
||||
);
|
||||
return outBlob;
|
||||
}
|
||||
|
||||
function revoke(url: string | null | undefined) {
|
||||
if (!url) return;
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------- Methods ----------
|
||||
type Canonical =
|
||||
| "ormbg"
|
||||
| "u2net"
|
||||
| "basnet"
|
||||
| "deeplab"
|
||||
| "tracer"
|
||||
| "u2net_human_seg"
|
||||
| "isnet-general-use"
|
||||
| "isnet-anime"
|
||||
| "bria"
|
||||
| "inspyrenet";
|
||||
|
||||
const METHODS: { key: Canonical; label: string }[] = [
|
||||
{ key: "ormbg", label: "ORMBG" },
|
||||
{ key: "u2net", label: "U2NET" },
|
||||
{ key: "basnet", label: "BASNET" },
|
||||
{ key: "deeplab", label: "DEEPLAB" },
|
||||
{ key: "tracer", label: "TRACER-B7" },
|
||||
{ key: "u2net_human_seg", label: "U2NET (Human)" },
|
||||
{ key: "isnet-general-use", label: "ISNET (General)" },
|
||||
{ key: "isnet-anime", label: "ISNET (Anime)" },
|
||||
{ key: "bria", label: "BRIA RMBG1.4" },
|
||||
{ key: "inspyrenet", label: "INSPYRENET" },
|
||||
];
|
||||
|
||||
const DEFAULT_CONCURRENCY = 2;
|
||||
|
||||
type Status = "idle" | "pending" | "ok" | "error";
|
||||
|
||||
type ResultMap = {
|
||||
[K in Canonical]?: {
|
||||
fullBlob: Blob;
|
||||
previewUrl: string;
|
||||
bytes: number;
|
||||
ms: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function BackgroundRemoverPage() {
|
||||
// ---------- State ----------
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<Record<Canonical, Status>>(() => {
|
||||
return 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);
|
||||
|
||||
// CHANGED: call local proxy instead of a hardcoded service
|
||||
const res = await fetch("/api/bgbye/process", {
|
||||
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);
|
||||
|
||||
// CHANGED: call local proxy instead of a hardcoded service
|
||||
const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const outBlob = await res.blob();
|
||||
const ms = performance.now() - t0;
|
||||
const previewBlob = await makePreview(outBlob);
|
||||
const previewUrl = URL.createObjectURL(previewBlob);
|
||||
const prev = resultsRef.current[active];
|
||||
if (prev) revoke(prev.previewUrl);
|
||||
|
||||
setResults((r) => ({
|
||||
...r,
|
||||
[active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms },
|
||||
}));
|
||||
setStatus((s) => ({ ...s, [active]: "ok" }));
|
||||
} catch {
|
||||
setStatus((s) => ({ ...s, [active]: "error" }));
|
||||
}
|
||||
}, [file, active]);
|
||||
|
||||
const doneCount = useMemo(
|
||||
() => METHODS.filter((m) => status[m.key] === "ok").length,
|
||||
[status]
|
||||
);
|
||||
const pendingCount = useMemo(
|
||||
() => METHODS.filter((m) => status[m.key] === "pending").length,
|
||||
[status]
|
||||
);
|
||||
|
||||
function StatusDot({ s }: { s: Status }) {
|
||||
const cls =
|
||||
s === "ok"
|
||||
? "bg-emerald-500"
|
||||
: s === "pending"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: s === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-600";
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${cls}`} />;
|
||||
}
|
||||
|
||||
// ---------- Render ----------
|
||||
return (
|
||||
<div className="p-6 text-zinc-100 overflow-x-hidden">
|
||||
{styles}
|
||||
|
||||
<div className="mx-auto w-full max-w-[1200px] px-4">
|
||||
{/* Header row: title left, back button right */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Background Remover</h1>
|
||||
<a
|
||||
href="/"
|
||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
|
||||
>
|
||||
Back to main
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Source filename */}
|
||||
<div className="text-zinc-400 mb-3">
|
||||
<span className="text-zinc-300">Source:</span>{" "}
|
||||
{file?.name ?? <span className="italic">— none —</span>}
|
||||
</div>
|
||||
|
||||
{/* Preview frame (border removed per your note) */}
|
||||
<div
|
||||
ref={frameRef}
|
||||
className="app-frame checkerboard relative w-full rounded-2xl 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>
|
||||
);
|
||||
}
|
||||
91
app/components/utilities/SVGNestPanel.tsx
Normal file
91
app/components/utilities/SVGNestPanel.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* SVGNest panel – same-origin iframe wrapper (clean, frameless).
|
||||
* Hides the legacy page’s heading/tagline and the "Back to Main" link.
|
||||
*/
|
||||
export default function SVGNestPanel() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = iframeRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onLoad = () => {
|
||||
setReady(true);
|
||||
|
||||
// Tidy up the embedded vendor page (no edits to files on disk)
|
||||
try {
|
||||
const doc = el.contentDocument;
|
||||
if (!doc) return;
|
||||
|
||||
// 1) Hide the first H1 (their big title)
|
||||
const h1 = doc.querySelector("h1");
|
||||
if (h1) (h1 as HTMLElement).style.display = "none";
|
||||
|
||||
// 2) Hide the tagline "automatically nests parts and exports svg"
|
||||
const all = Array.from(doc.querySelectorAll<HTMLElement>("body *"));
|
||||
for (const n of all) {
|
||||
const t = (n.textContent || "").trim().toLowerCase();
|
||||
if (t.includes("automatically nests parts") && t.includes("exports svg")) {
|
||||
n.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Hide "Back to Main" link(s)
|
||||
const anchors = Array.from(doc.querySelectorAll<HTMLAnchorElement>("a"));
|
||||
for (const a of anchors) {
|
||||
const text = (a.textContent || "").trim().toLowerCase();
|
||||
const href = a.getAttribute("href") || "";
|
||||
const isBackText = text === "back to main" || text === "← back to main";
|
||||
const isRootHref =
|
||||
href === "/" ||
|
||||
/^https?:\/\/[^/]+\/?$/.test(href); // absolute site root
|
||||
if (isBackText || isRootHref) {
|
||||
(a as HTMLElement).style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Tighten top spacing a touch
|
||||
(doc.body as HTMLElement).style.marginTop = "4px";
|
||||
} catch {
|
||||
// same-origin, so this *should* succeed; ignore if not
|
||||
}
|
||||
};
|
||||
|
||||
const onError = () => setErr("Failed to load /svgnest/index.html");
|
||||
|
||||
el.addEventListener("load", onLoad);
|
||||
el.addEventListener("error", onError);
|
||||
return () => {
|
||||
el.removeEventListener("load", onLoad);
|
||||
el.removeEventListener("error", onError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!ready && !err && (
|
||||
<div className="mb-2 text-sm text-zinc-400">Loading SVGnest…</div>
|
||||
)}
|
||||
{err && (
|
||||
<div className="mb-2 rounded border border-rose-600/40 bg-rose-600/10 p-3 text-sm">
|
||||
Couldn’t load SVGnest: {err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frameless iframe (no border, no wrapper frame) */}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/svgnest/index.html"
|
||||
title="SVGNest"
|
||||
className="w-full"
|
||||
style={{ height: "72vh", background: "transparent", border: "none" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
app/components/utilities/files/FileBrowserPanel.tsx
Normal file
300
app/components/utilities/files/FileBrowserPanel.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Download,
|
||||
Folder,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
ArrowUp,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
type: "dir" | "file";
|
||||
size?: number;
|
||||
mtimeMs?: number;
|
||||
};
|
||||
|
||||
type ListResponse =
|
||||
| { path: string; items: FileItem[] }
|
||||
| { path: string; entries: FileItem[] }
|
||||
| { items?: FileItem[]; entries?: FileItem[]; path?: string };
|
||||
|
||||
function joinPath(a: string, b: string) {
|
||||
if (!a || a === "/") return b.startsWith("/") ? b : `/${b}`;
|
||||
return `${a.replace(/\/$/, "")}/${b.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
function parentPath(path: string) {
|
||||
if (!path || path === "/") return "/";
|
||||
const parts = path.replace(/\/+$/, "").split("/");
|
||||
parts.pop();
|
||||
const p = parts.join("/");
|
||||
return p === "" ? "/" : p;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number) {
|
||||
if (bytes == null) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = bytes;
|
||||
let u = 0;
|
||||
while (v >= 1024 && u < units.length - 1) {
|
||||
v /= 1024;
|
||||
u++;
|
||||
}
|
||||
return `${v.toFixed(u ? 1 : 0)} ${units[u]}`;
|
||||
}
|
||||
|
||||
function formatDate(ms?: number) {
|
||||
if (!ms) return "—";
|
||||
const d = new Date(ms);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function FileBrowserPanel() {
|
||||
const [path, setPath] = useState<string>("/");
|
||||
const [items, setItems] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewHref, setPreviewHref] = useState<string | null>(null);
|
||||
|
||||
const urlList = useMemo(() => {
|
||||
const p = encodeURIComponent(path || "/");
|
||||
return `/api/files/list?path=${p}`;
|
||||
}, [path]);
|
||||
|
||||
const urlDownload = useCallback((p: string) => {
|
||||
const qp = encodeURIComponent(p || "/");
|
||||
return `/api/files/download?path=${qp}`;
|
||||
}, []);
|
||||
|
||||
const urlRaw = useCallback((p: string) => {
|
||||
const qp = encodeURIComponent(p || "/");
|
||||
return `/api/files/raw?path=${qp}`;
|
||||
}, []);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPreviewHref(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(urlList, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const json: ListResponse = await res.json();
|
||||
const arr = (json as any).items || (json as any).entries || [];
|
||||
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error("Malformed list response");
|
||||
}
|
||||
|
||||
arr.sort((a: FileItem, b: FileItem) =>
|
||||
a.type !== b.type
|
||||
? a.type === "dir"
|
||||
? -1
|
||||
: 1
|
||||
: a.name.localeCompare(b.name)
|
||||
);
|
||||
|
||||
setItems(arr);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [urlList]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const onOpen = (it: FileItem) => {
|
||||
if (it.type === "dir") {
|
||||
setPath((p) => joinPath(p, it.name));
|
||||
} else {
|
||||
setPreviewHref(urlRaw(joinPath(path, it.name)));
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => setPath((p) => parentPath(p));
|
||||
const onHome = () => setPath("/");
|
||||
|
||||
const onDownload = (it: FileItem) => {
|
||||
const href = urlDownload(joinPath(path, it.name));
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = it.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<style>{`
|
||||
.fs-cell { font-size: 13.5px; line-height: 1.3 }
|
||||
.fs-tight { letter-spacing: .01em }
|
||||
.fs-table {
|
||||
--col-name-min: 260px;
|
||||
--col-type: 56px;
|
||||
--col-size: 72px;
|
||||
--col-date: 96px;
|
||||
--col-actions: 44px;
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(var(--col-name-min), 1fr)
|
||||
var(--col-type)
|
||||
var(--col-size)
|
||||
var(--col-date)
|
||||
var(--col-actions);
|
||||
}
|
||||
.fs-nowrap { white-space: nowrap }
|
||||
.fs-iconbtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onHome}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Root"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
root
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchList}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onUp}
|
||||
className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
title="Up one folder"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="select-all text-xs text-muted-foreground">{path || "/"}</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(560px,1fr)_minmax(420px,42vw)] items-start gap-3">
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<div className="fs-table bg-muted px-2 py-2 fs-cell fs-tight">
|
||||
<div className="px-1">Name</div>
|
||||
<div className="text-center">Type</div>
|
||||
<div className="text-right">Size</div>
|
||||
<div className="text-center">Date</div>
|
||||
<div className="fs-nowrap pr-1 text-right">Get</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-sm text-rose-400">Error: {error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">Empty folder.</div>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<div
|
||||
key={it.name + it.type}
|
||||
className="fs-table fs-cell items-center px-2 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<button
|
||||
className="min-w-0 px-1 text-left inline-flex items-center gap-2 hover:underline"
|
||||
onClick={() => onOpen(it)}
|
||||
title={it.name}
|
||||
>
|
||||
{it.type === "dir" ? (
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{it.name}</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{it.type}
|
||||
</div>
|
||||
|
||||
<div className="text-right text-muted-foreground">
|
||||
{formatSize(it.size)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-muted-foreground">
|
||||
{formatDate(it.mtimeMs)}
|
||||
</div>
|
||||
|
||||
<div className="pr-1 text-right">
|
||||
{it.type === "file" && (
|
||||
<button
|
||||
onClick={() => onDownload(it)}
|
||||
className="fs-iconbtn hover:bg-muted"
|
||||
title="Download"
|
||||
aria-label={`Download ${it.name}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-2 text-sm font-medium">Preview</div>
|
||||
<div className="overflow-hidden rounded border" style={{ height: 520 }}>
|
||||
{previewHref ? (
|
||||
<iframe
|
||||
key={previewHref}
|
||||
src={previewHref}
|
||||
className="h-full w-full"
|
||||
title="Preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Select a file to preview.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
app/components/utilities/files/FilePreview.tsx
Normal file
56
app/components/utilities/files/FilePreview.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// components/utilities/files/FilePreview.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { rawUrl, isPreviewableImage, isPreviewableText, isPreviewablePdf } from "./api";
|
||||
|
||||
export default function FilePreview({ path, mime, name }: { path: string; mime?: string | null; name?: string }) {
|
||||
const [text, setText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setText("");
|
||||
if (isPreviewableText(mime, name)) {
|
||||
try {
|
||||
const res = await fetch(rawUrl(path), { cache: "no-store" });
|
||||
const t = await res.text();
|
||||
if (!cancelled) setText(t.slice(0, 100_000)); // safety cap
|
||||
} catch {
|
||||
if (!cancelled) setText("Unable to load text preview.");
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [path, mime, name]);
|
||||
|
||||
if (isPreviewableImage(mime, name)) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={rawUrl(path)} alt={name || ""} className="w-full max-h-[60vh] object-contain bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPreviewablePdf(mime, name)) {
|
||||
return (
|
||||
<iframe
|
||||
src={rawUrl(path)}
|
||||
className="w-full h-[60vh] rounded-md border"
|
||||
title={name || "PDF preview"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPreviewableText(mime, name)) {
|
||||
return (
|
||||
<pre className="rounded-md border bg-muted/40 p-3 overflow-auto max-h-[60vh] text-xs whitespace-pre-wrap">
|
||||
{text || "Loading…"}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-sm text-zinc-400">No preview available.</div>;
|
||||
}
|
||||
112
app/components/utilities/files/FilesTable.tsx
Normal file
112
app/components/utilities/files/FilesTable.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// components/utilities/files/FilesTable.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ArrowDownAZ, ArrowUpAZ, Download, Folder, FileText } from "lucide-react";
|
||||
import { FsEntry, SortKey, SortDir, nicelyFormatBytes } from "./api";
|
||||
|
||||
export default function FilesTable({
|
||||
entries,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
onOpen,
|
||||
onDownload,
|
||||
}: {
|
||||
entries: FsEntry[];
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (k: SortKey) => void;
|
||||
onOpen: (entry: FsEntry) => void;
|
||||
onDownload: (entry: FsEntry) => void;
|
||||
}) {
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...entries];
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
arr.sort((a, b) => {
|
||||
// folders first
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
|
||||
switch (sortKey) {
|
||||
case "name": return a.name.localeCompare(b.name) * dir;
|
||||
case "size": return ((a.size ?? -1) - (b.size ?? -1)) * dir;
|
||||
case "modified": return (new Date(a.modified || 0).getTime() - new Date(b.modified || 0).getTime()) * dir;
|
||||
case "type": {
|
||||
const ax = ext(a.name), bx = ext(b.name);
|
||||
return ax.localeCompare(bx) * dir;
|
||||
}
|
||||
}
|
||||
});
|
||||
return arr;
|
||||
}, [entries, sortKey, sortDir]);
|
||||
|
||||
function ext(name: string) {
|
||||
const m = /\.([^.]+)$/.exec(name || "");
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function SortBtn({ k, label }: { k: SortKey; label: string }) {
|
||||
const active = sortKey === k;
|
||||
return (
|
||||
<button className="inline-flex items-center gap-1 text-left" onClick={() => onSort(k)}>
|
||||
{label}
|
||||
{active ? (
|
||||
sortDir === "asc" ? <ArrowUpAZ className="w-3.5 h-3.5 opacity-60" /> : <ArrowDownAZ className="w-3.5 h-3.5 opacity-60" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-zinc-400 bg-muted/40">
|
||||
<tr>
|
||||
<th className="px-3 py-2 w-[40%]"><SortBtn k="name" label="Name" /></th>
|
||||
<th className="px-3 py-2 w-[15%]"><SortBtn k="type" label="Type" /></th>
|
||||
<th className="px-3 py-2 w-[15%]"><SortBtn k="size" label="Size" /></th>
|
||||
<th className="px-3 py-2 w-[30%]"><SortBtn k="modified" label="Modified" /></th>
|
||||
<th className="px-3 py-2 text-right">Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((e) => (
|
||||
<tr key={e.path} className="border-t hover:bg-muted/30">
|
||||
<td
|
||||
className="px-3 py-2 cursor-pointer"
|
||||
onDoubleClick={() => onOpen(e)}
|
||||
title="Double-click to open"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{e.isDir ? <Folder className="w-4 h-4 opacity-70" /> : <FileText className="w-4 h-4 opacity-70" />}
|
||||
<span className="truncate">{e.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">{e.isDir ? "Folder" : (e.mime || ext(e.name).toUpperCase() || "File")}</td>
|
||||
<td className="px-3 py-2">{e.isDir ? "—" : nicelyFormatBytes(e.size)}</td>
|
||||
<td className="px-3 py-2">{e.modified ? new Date(e.modified).toLocaleString() : "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
{!e.isDir && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="rounded-md border px-2 py-1 text-xs hover:bg-muted inline-flex items-center gap-1"
|
||||
onClick={() => onDownload(e)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-sm text-zinc-500" colSpan={5}>Empty folder.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
app/components/utilities/files/api.ts
Normal file
81
app/components/utilities/files/api.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// components/utilities/files/api.ts
|
||||
export type FsEntry = {
|
||||
name: string;
|
||||
path: string; // absolute or from root, e.g. "/public" or "/public/readme.txt"
|
||||
isDir: boolean;
|
||||
size?: number | null; // bytes
|
||||
modified?: string | null; // ISO date string
|
||||
mime?: string | null; // server-provided mime, optional
|
||||
};
|
||||
|
||||
export type ListResponse = {
|
||||
cwd: string; // normalized path we listed
|
||||
entries: FsEntry[]; // unsorted list
|
||||
};
|
||||
|
||||
export type SortKey = "name" | "size" | "modified" | "type";
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export async function list(path: string): Promise<ListResponse> {
|
||||
const u = new URL("/api/files/list", location.origin);
|
||||
if (path) u.searchParams.set("path", path);
|
||||
const res = await fetch(u.toString(), { credentials: "include", cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`list ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function rawUrl(path: string): string {
|
||||
const u = new URL("/api/files/raw", location.origin);
|
||||
u.searchParams.set("path", path);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export async function download(path: string): Promise<void> {
|
||||
// try direct browser download via a hidden <a download>
|
||||
const u = new URL("/api/files/download", location.origin);
|
||||
u.searchParams.set("path", path);
|
||||
const a = document.createElement("a");
|
||||
a.href = u.toString();
|
||||
a.rel = "noopener";
|
||||
a.download = ""; // hint to save-as
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export function parentDir(p: string): string {
|
||||
if (!p || p === "/") return "/";
|
||||
const segs = p.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
segs.pop();
|
||||
return "/" + segs.join("/");
|
||||
}
|
||||
|
||||
export function nicelyFormatBytes(n?: number | null): string {
|
||||
if (!Number.isFinite(n as number) || (n as number) < 0) return "—";
|
||||
const b = n as number;
|
||||
if (b < 1024) return `${b} B`;
|
||||
const units = ["KB","MB","GB","TB"];
|
||||
let v = b / 1024, i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function extFromName(name: string): string {
|
||||
const m = /\.([^.]+)$/.exec(name || "");
|
||||
return m ? m[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function isPreviewableImage(mime?: string | null, name?: string): boolean {
|
||||
const ext = extFromName(name || "");
|
||||
return /^image\//.test(mime || "") || ["png","jpg","jpeg","gif","webp","bmp","svg"].includes(ext);
|
||||
}
|
||||
|
||||
export function isPreviewableText(mime?: string | null, name?: string): boolean {
|
||||
const ext = extFromName(name || "");
|
||||
return /^text\//.test(mime || "") || ["txt","csv","md","json","log"].includes(ext);
|
||||
}
|
||||
|
||||
export function isPreviewablePdf(mime?: string | null, name?: string): boolean {
|
||||
const ext = extFromName(name || "");
|
||||
return (mime || "").includes("pdf") || ext === "pdf";
|
||||
}
|
||||
39
app/components/utilities/laser-toolkit/_lib/conversions.ts
Normal file
39
app/components/utilities/laser-toolkit/_lib/conversions.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
113
app/components/utilities/laser-toolkit/dpi-lpi-dpcm/page.tsx
Normal file
113
app/components/utilities/laser-toolkit/dpi-lpi-dpcm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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.05–1.15</span> (simple paths, long runs closer to 1.05; tiny
|
||||
segments or lots of jumps closer to 1.15). Raster engraving{" "}
|
||||
<span className="font-medium">1.10–1.40</span> (lower DPI and long sweeps near 1.10;
|
||||
very high DPI or short scan width near 1.30–1.40). Galvo systems often have lower overhead
|
||||
at small sizes; gantry systems tend to have higher overhead at high DPI/short strokes.
|
||||
</p>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 % > 100, prefer adjusting speed/frequency</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Source */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Source (what you have)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Rated power (W)</Label>
|
||||
<Input value={wSrc} onChange={(e) => setWSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Power (%)</Label>
|
||||
<Input value={pSrc} onChange={(e) => setPSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Frequency (kHz)</Label>
|
||||
<Input value={fSrc} onChange={(e) => setFSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Pulse width (ns)</Label>
|
||||
<Input value={tauSrc} onChange={(e) => setTauSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Speed (mm/s)</Label>
|
||||
<Input value={vSrc} onChange={(e) => setVSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Line spacing h (mm)</Label>
|
||||
<Input value={hSrc} onChange={(e) => setHSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Lens field size (mm)</Label>
|
||||
<Input value={fieldSrc} onChange={(e) => setFieldSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<button
|
||||
className="text-xs underline text-muted-foreground"
|
||||
onClick={() => setAdvanced((s) => !s)}
|
||||
>
|
||||
{advanced ? 'Hide' : 'Show'} advanced frequency curve
|
||||
</button>
|
||||
<div className={cn('mt-3 grid gap-4 md:grid-cols-3', advanced ? 'block' : 'hidden')}>
|
||||
<div>
|
||||
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
|
||||
<Input value={fPeakSrc} onChange={(e) => setFPeakSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Curve width σ (log-normal)</Label>
|
||||
<Input value={sigmaSrc} onChange={(e) => setSigmaSrc(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className="flex items-end text-xs text-muted-foreground">
|
||||
η(f) is log-normal; 1.0 at fₚ, rolls off by σ.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Destination */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Destination (what you want to run on)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Rated power (W)</Label>
|
||||
<Input value={wDst} onChange={(e) => setWDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Frequency (kHz)</Label>
|
||||
<Input value={fDst} onChange={(e) => setFDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Pulse width (ns)</Label>
|
||||
<Input value={tauDst} onChange={(e) => setTauDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode !== 'irradiance' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Speed (mm/s)</Label>
|
||||
<Input value={vDst} onChange={(e) => setVDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className={cn(mode === 'raster' ? 'block' : 'hidden')}>
|
||||
<Label className="text-sm">Line spacing h (mm)</Label>
|
||||
<Input value={hDst} onChange={(e) => setHDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Lens field size (mm)</Label>
|
||||
<Input value={fieldDst} onChange={(e) => setFieldDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardContent className={cn('pt-0', advanced ? 'block' : 'hidden')}>
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="text-sm">Peak freq fₚ (kHz)</Label>
|
||||
<Input value={fPeakDst} onChange={(e) => setFPeakDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Curve width σ (log-normal)</Label>
|
||||
<Input value={sigmaDst} onChange={(e) => setSigmaDst(e.target.value)} inputMode="decimal" />
|
||||
</div>
|
||||
<div className="flex items-end text-xs text-muted-foreground">
|
||||
Adjust if you know your machine’s real power–frequency curve.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Result</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-2xl font-semibold">
|
||||
Suggested Power (dest): {result.p2Percent.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
{typeof result.suggestedSpeed === 'number' && mode !== 'pulse' && (
|
||||
<p className="text-sm">
|
||||
To keep Power ≤ 100%, try destination speed ≈{' '}
|
||||
<span className="font-medium">{result.suggestedSpeed.toFixed(1)} mm/s</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{typeof result.suggestedFreq_kHz === 'number' && mode === 'pulse' && (
|
||||
<p className="text-sm">
|
||||
To keep Power ≤ 100%, try destination frequency ≈{' '}
|
||||
<span className="font-medium">{result.suggestedFreq_kHz.toFixed(0)} kHz</span>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">η(f) source / dest</div>
|
||||
<div className="font-medium">{result.eta1.toFixed(3)} / {result.eta2.toFixed(3)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Dest pulse energy</div>
|
||||
<div className="font-medium">
|
||||
{(result.Ep2 >= 1e-3 ? (result.Ep2 * 1e3).toFixed(3) + ' mJ' : (result.Ep2 * 1e6).toFixed(1) + ' µJ')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Dest peak power</div>
|
||||
<div className="font-medium">{(result.Ppeak2 / 1000).toFixed(1)} kW</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Assumptions: Effective power includes a frequency efficiency factor η(f). Peak power uses a rectangular pulse
|
||||
approximation (shape factor ≈ 1). For real MOPA sources, pulse shape and
|
||||
true power–frequency maps vary by model; adjust f<sub>p</sub> and σ if you have vendor curves.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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">
|
||||
60–80% overlap is common for marking; deeper engraving often higher.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ToolShell>
|
||||
);
|
||||
}
|
||||
|
||||
52
app/components/utilities/laser-toolkit/registry.ts
Normal file
52
app/components/utilities/laser-toolkit/registry.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// components/utilities/laser-toolkit/registry.ts
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export type ToolkitTab = {
|
||||
key: string; // used in ?lt=<key>
|
||||
label: string; // tab label
|
||||
component: React.ComponentType<{}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Points directly at your existing files:
|
||||
* - beam-spot-size/page.tsx
|
||||
* - dpi-lpi-dpcm/page.tsx
|
||||
* - hatch-overlap/page.tsx
|
||||
* - job-time-estimator/page.tsx
|
||||
* - power-lens-scaler/page.tsx
|
||||
* - pulse-overlap/page.tsx
|
||||
*/
|
||||
export const TOOLKIT_TABS: ToolkitTab[] = [
|
||||
{
|
||||
key: "beam-spot-size",
|
||||
label: "Beam Spot Size",
|
||||
component: dynamic(() => import("./beam-spot-size/page"), { ssr: false }),
|
||||
},
|
||||
{
|
||||
key: "dpi-lpi-dpcm",
|
||||
label: "DPI / LPI / DPCM",
|
||||
component: dynamic(() => import("./dpi-lpi-dpcm/page"), { ssr: false }),
|
||||
},
|
||||
{
|
||||
key: "hatch-overlap",
|
||||
label: "Hatch Overlap",
|
||||
component: dynamic(() => import("./hatch-overlap/page"), { ssr: false }),
|
||||
},
|
||||
{
|
||||
key: "job-time-estimator",
|
||||
label: "Job Time Estimator",
|
||||
component: dynamic(() => import("./job-time-estimator/page"), { ssr: false }),
|
||||
},
|
||||
{
|
||||
key: "power-lens-scaler",
|
||||
label: "Power / Lens Scaler",
|
||||
component: dynamic(() => import("./power-lens-scaler/page"), { ssr: false }),
|
||||
},
|
||||
{
|
||||
key: "pulse-overlap",
|
||||
label: "Pulse Overlap",
|
||||
component: dynamic(() => import("./pulse-overlap/page"), { ssr: false }),
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue