added basic UtilitySwitcher for utility access in-app

This commit is contained in:
makearmy 2025-09-30 23:05:29 -04:00
parent c3fe52589f
commit ef7b5b2588
3 changed files with 208 additions and 7 deletions

View file

@ -0,0 +1,19 @@
// app/portal/utilities/Client.tsx
"use client";
import dynamic from "next/dynamic";
const UtilitySwitcher = dynamic(() => import("@/components/portal/UtilitySwitcher"), {
ssr: false,
});
export default function UtilitiesClient() {
return (
<div className="space-y-4">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-semibold">Utilities</h2>
<UtilitySwitcher />
</div>
</div>
);
}

View file

@ -1,9 +1,8 @@
// app/portal/utilities/page.tsx
export default function UtilitiesPage() {
return (
<div className="rounded-lg border p-6">
<h2 className="text-xl font-semibold mb-2">Utilities</h2>
<p className="opacity-80">WIP: calculators, helpers, import/export, etc.</p>
</div>
);
import UtilitiesClient from "./Client";
export const metadata = { title: "MakerDash • Utilities" };
export default function Page() {
return <UtilitiesClient />;
}

View file

@ -0,0 +1,183 @@
// components/portal/UtilitySwitcher.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
/** Raw catalog (from your old dashboard) */
const RAW_ITEMS = [
{
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 (open in 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",
},
] as const;
type Item = (typeof RAW_ITEMS)[number];
type Tab = "onsite" | "subdomains";
const TABS: { key: Tab; label: string }[] = [
{ key: "onsite", label: "On-site" },
{ key: "subdomains", label: "Subdomains" },
];
function classify(items: readonly 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 {
// if it isn't a URL for some reason, treat as on-site path
onsite.push(it as Item);
}
}
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) => {
// derive icon path (put your images wherever you keep them)
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) => {
// tiny fallback if an icon is missing
(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>
);
}