Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue