makearmy-app/components/portal/UtilitySwitcher.tsx

265 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/portal/UtilitySwitcher.tsx
"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[] = [
// ✅ Laser Toolkit now renders inline with sub-tabs
{
key: "laser-toolkit",
label: "Laser Toolkit",
note: "convert laser settings, interval and more",
icon: "toolkit.png",
component: LaserToolkitSwitcher,
href: "https://makearmy.io/laser-toolkit", // optional; component takes precedence
},
// ✅ File Server inline (no iframe)
{
key: "files",
label: "File Server",
note: "download from our file explorer",
icon: "fs.png",
component: FileBrowserPanel,
href: "https://makearmy.io/files",
},
// Buying Guide moved to main portal tab — remove here to avoid duplication
// { key: "buying-guide", ... }
// ✅ SVGnest inline (micro-frontend wrapper)
{
key: "svgnest",
label: "SVGnest",
note: "automatically nests parts and exports svg",
icon: "nest.png",
component: SVGNestPanel,
href: "https://makearmy.io/svgnest",
},
// ✅ Background Remover inline
{
key: "background-remover",
label: "BG Remover",
note: "open source background remover",
icon: "bgrm.png",
component: BackgroundRemoverPanel,
href: "https://makearmy.io/background-remover",
},
// Subdomains (new tab)
{
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: "forgejo.png",
href: "https://forge.makearmy.io",
},
];
function isExternal(urlStr: string | undefined) {
if (!urlStr) return false;
try {
const u = new URL(urlStr);
return u.hostname !== "makearmy.io";
} catch {
return true;
}
}
/** For on-site URLs, convert absolute https://makearmy.io/path → /path for iframe src */
function toOnsitePath(urlStr: string): string {
try {
const u = new URL(urlStr);
if (u.hostname === "makearmy.io") {
return `${u.pathname}${u.search}${u.hash}`;
}
} catch {}
return urlStr;
}
function Panel({ item }: { item: Item }) {
if (item.component) {
const Cmp = item.component;
return (
<div className="space-y-3">
{item.note ? <div className="text-sm opacity-70">{item.note}</div> : null}
<Cmp embedded />
</div>
);
}
const external = isExternal(item.href);
if (external) {
return (
<div className="space-y-2 text-sm">
<div>
Opened <span className="font-medium">{item.label}</span> in a new tab.
</div>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Click here if it didnt open.
</a>
</div>
);
}
const src = toOnsitePath(item.href || "/");
return (
<div className="space-y-3">
{item.note ? <div className="text-sm opacity-70">{item.note}</div> : null}
<iframe
key={src}
src={src}
className="w-full h-[72vh] rounded-md border"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
</div>
);
}
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 });
}
useEffect(() => {
const item = activeItem;
if (!item) return;
if (item.component) return;
const external = isExternal(item.href);
if (!external) return;
if (openedRef.current === item.key) return;
openedRef.current = item.key;
const AUTO_OPEN_ON_FIRST_PAINT = true;
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
window.open(item.href!, "_blank", "noopener,noreferrer");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeItem?.key, activeItem?.href]);
useEffect(() => {
setFirstPaint(false);
}, []);
return (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
{ITEMS.map((it) => {
const isInline = Boolean(it.component);
const external = !isInline && isExternal(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>
<div className="rounded-md border p-4">
<Panel item={activeItem} />
</div>
</div>
);
}