le-app/app/components/portal/UtilitySwitcher.tsx

281 lines
8.5 KiB
TypeScript
Raw Permalink Normal View History

"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";
2026-07-10 17:25:34 -04:00
import { ChevronRight, ExternalLink, TerminalSquare } from "lucide-react";
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 (
2026-07-10 17:25:34 -04:00
<div className="grid min-w-0 gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<aside className="h-fit overflow-hidden rounded-xl border border-border/70 bg-card/40 lg:sticky lg:top-4">
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2.5 text-[11px] uppercase tracking-[0.16em] text-muted-foreground">
<TerminalSquare className="h-3.5 w-3.5 text-accent" />
Select utility
</div>
<div className="grid grid-cols-2 gap-1 p-1.5 sm:grid-cols-3 lg:grid-cols-1">
{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(
2026-07-10 17:25:34 -04:00
"group flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-xs transition-all sm:text-sm",
isActive
2026-07-10 17:25:34 -04:00
? "bg-accent/15 text-foreground shadow-[inset_2px_0_0_hsl(var(--accent))]"
: "text-muted-foreground hover:bg-muted/30 hover:text-foreground"
)}
title={it.note || it.label}
>
{iconSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={iconSrc}
alt=""
width={16}
height={16}
2026-07-10 17:25:34 -04:00
className="h-5 w-5 shrink-0 rounded border border-border/60 object-cover opacity-80"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : null}
2026-07-10 17:25:34 -04:00
<span className="min-w-0 flex-1 truncate">{it.label}</span>
{!isInline && external && (
2026-07-10 17:25:34 -04:00
<ExternalLink className="h-3 w-3 shrink-0 opacity-50" />
)}
2026-07-10 17:25:34 -04:00
{isInline && <ChevronRight className="hidden h-3.5 w-3.5 shrink-0 opacity-0 transition group-hover:opacity-50 lg:block" />}
</button>
);
})}
2026-07-10 17:25:34 -04:00
</div></aside>
2026-07-10 17:25:34 -04:00
<section className="min-w-0 overflow-hidden rounded-xl border border-border/70 bg-card/20 shadow-sm">
<Panel item={activeItem} />
</section>
</div>
);
}