190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
// components/portal/UtilitySwitcher.tsx
|
|
"use client";
|
|
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Item = {
|
|
label: string;
|
|
note: string;
|
|
icon: string;
|
|
href: string;
|
|
check?: string;
|
|
/** present only for subdomain links */
|
|
target?: "_blank";
|
|
};
|
|
|
|
type Tab = "onsite" | "subdomains";
|
|
|
|
const TABS: { key: Tab; label: string }[] = [
|
|
{ key: "onsite", label: "On-site" },
|
|
{ key: "subdomains", label: "Subdomains" },
|
|
];
|
|
|
|
/** Raw catalog (from your old dashboard) */
|
|
const RAW_ITEMS: Item[] = [
|
|
// --- on-site (same tab)
|
|
{
|
|
label: "Laser Toolkit",
|
|
note: "convert laser settings, interval and more",
|
|
icon: "toolkit.png",
|
|
href: "https://makearmy.io/laser-toolkit",
|
|
check: "https://makearmy.io/laser-toolkit",
|
|
},
|
|
{
|
|
label: "File Server",
|
|
note: "download from our file explorer",
|
|
icon: "fs.png",
|
|
href: "https://makearmy.io/files",
|
|
check: "https://makearmy.io/files",
|
|
},
|
|
{
|
|
label: "Buying Guide",
|
|
note: "reviews and listings for relevant products",
|
|
icon: "bg.png",
|
|
href: "https://makearmy.io/buying-guide",
|
|
check: "https://makearmy.io/buying-guide",
|
|
},
|
|
{
|
|
label: "SVGnest",
|
|
note: "automatically nests parts and exports svg",
|
|
icon: "nest.png",
|
|
href: "https://makearmy.io/svgnest",
|
|
check: "https://makearmy.io/svgnest",
|
|
},
|
|
{
|
|
label: "BG Remover",
|
|
note: "advanced open source background remover featuring 10 AI models",
|
|
icon: "bgrm.png",
|
|
href: "https://makearmy.io/background-remover",
|
|
check: "https://makearmy.io/background-remover",
|
|
},
|
|
// --- subdomains (new tab)
|
|
{
|
|
label: "Picsur",
|
|
note: "Simple Image Host",
|
|
icon: "picsur.png",
|
|
href: "https://images.makearmy.io",
|
|
target: "_blank",
|
|
check: "https://images.makearmy.io",
|
|
},
|
|
{
|
|
label: "PrivateBin",
|
|
note: "Your encrypted internet clipboard.",
|
|
icon: "privatebin.png",
|
|
href: "https://paste.makearmy.io/",
|
|
target: "_blank",
|
|
check: "https://paste.makearmy.io/",
|
|
},
|
|
{
|
|
label: "Forgejo",
|
|
note: "git for our community members",
|
|
icon: "forgejo.png",
|
|
href: "https://forge.makearmy.io",
|
|
target: "_blank",
|
|
check: "https://forge.makearmy.io",
|
|
},
|
|
];
|
|
|
|
function classify(items: Item[]) {
|
|
const onsite: Item[] = [];
|
|
const subdomains: Item[] = [];
|
|
for (const it of items) {
|
|
try {
|
|
const u = new URL(it.href);
|
|
if (u.hostname === "makearmy.io") onsite.push(it);
|
|
else subdomains.push(it);
|
|
} catch {
|
|
// Treat malformed URLs as on-site paths
|
|
onsite.push(it);
|
|
}
|
|
}
|
|
return { onsite, subdomains };
|
|
}
|
|
|
|
const { onsite: ONSITE, subdomains: SUBS } = classify(RAW_ITEMS);
|
|
|
|
function Grid({ items, external }: { items: Item[]; external: boolean }) {
|
|
return (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{items.map((it) => {
|
|
const iconSrc = `/images/utils/${it.icon}`;
|
|
const isExternal = external || it.target === "_blank";
|
|
return (
|
|
<a
|
|
key={it.href}
|
|
href={it.href}
|
|
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
className="group rounded-md border p-3 transition hover:bg-muted focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={iconSrc}
|
|
alt=""
|
|
width={32}
|
|
height={32}
|
|
className="h-8 w-8 rounded-md border object-cover"
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="truncate font-medium">{it.label}</div>
|
|
{isExternal && (
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
new tab
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-sm text-muted-foreground">{it.note}</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function UtilitySwitcher() {
|
|
const router = useRouter();
|
|
const sp = useSearchParams();
|
|
|
|
const activeRaw = (sp.get("t") || "onsite").toLowerCase();
|
|
const active: Tab = (TABS.some((t) => t.key === activeRaw) ? activeRaw : "onsite") as Tab;
|
|
|
|
function setTab(next: Tab) {
|
|
const q = new URLSearchParams(sp.toString());
|
|
q.set("t", next);
|
|
router.replace(`/portal/utilities?${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">
|
|
{active === "onsite" ? (
|
|
<Grid items={ONSITE} external={false} />
|
|
) : (
|
|
<Grid items={SUBS} external={true} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|