added basic UtilitySwitcher for utility access in-app
This commit is contained in:
parent
c3fe52589f
commit
ef7b5b2588
3 changed files with 208 additions and 7 deletions
19
app/portal/utilities/Client.tsx
Normal file
19
app/portal/utilities/Client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
183
components/portal/UtilitySwitcher.tsx
Normal file
183
components/portal/UtilitySwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue