makearmy-app/components/portal/UtilitySwitcher.tsx

233 lines
6.7 KiB
TypeScript
Raw Normal View History

// components/portal/UtilitySwitcher.tsx
"use client";
2025-09-30 23:12:52 -04:00
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
2025-09-30 23:08:06 -04:00
type Item = {
2025-09-30 23:12:52 -04:00
key: string; // used in ?t=
2025-09-30 23:08:06 -04:00
label: string;
2025-09-30 23:12:52 -04:00
note?: string;
icon?: string; // optional icon (public/images/utils/<icon>)
href: string; // absolute URL
2025-09-30 23:08:06 -04:00
};
2025-09-30 23:12:52 -04:00
const ITEMS: Item[] = [
// On-site (embed)
{
2025-09-30 23:12:52 -04:00
key: "laser-toolkit",
label: "Laser Toolkit",
note: "convert laser settings, interval and more",
icon: "toolkit.png",
href: "https://makearmy.io/laser-toolkit",
},
{
2025-09-30 23:12:52 -04:00
key: "files",
label: "File Server",
note: "download from our file explorer",
icon: "fs.png",
href: "https://makearmy.io/files",
},
{
2025-09-30 23:12:52 -04:00
key: "buying-guide",
label: "Buying Guide",
note: "reviews and listings for relevant products",
icon: "bg.png",
href: "https://makearmy.io/buying-guide",
},
{
2025-09-30 23:12:52 -04:00
key: "svgnest",
label: "SVGnest",
note: "automatically nests parts and exports svg",
icon: "nest.png",
href: "https://makearmy.io/svgnest",
},
{
2025-09-30 23:12:52 -04:00
key: "background-remover",
label: "BG Remover",
2025-09-30 23:12:52 -04:00
note: "open source background remover",
icon: "bgrm.png",
href: "https://makearmy.io/background-remover",
},
2025-09-30 23:12:52 -04:00
// Subdomains (new tab)
{
2025-09-30 23:12:52 -04:00
key: "picsur",
label: "Picsur",
note: "Simple Image Host",
icon: "picsur.png",
href: "https://images.makearmy.io",
},
{
2025-09-30 23:12:52 -04:00
key: "privatebin",
label: "PrivateBin",
2025-09-30 23:12:52 -04:00
note: "Encrypted internet clipboard",
icon: "privatebin.png",
href: "https://paste.makearmy.io/",
},
{
2025-09-30 23:12:52 -04:00
key: "forgejo",
label: "Forgejo",
note: "git for our community members",
icon: "forgejo.png",
href: "https://forge.makearmy.io",
},
];
2025-09-30 23:12:52 -04:00
function isExternal(urlStr: string) {
try {
const u = new URL(urlStr);
return u.hostname !== "makearmy.io";
} catch {
return true;
}
}
2025-09-30 23:12:52 -04:00
/** 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 }) {
const external = isExternal(item.href);
2025-09-30 23:12:52 -04:00
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 (
2025-09-30 23:12:52 -04:00
<div className="space-y-3">
<div className="text-sm opacity-70">{item.note}</div>
<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();
2025-09-30 23:12:52 -04:00
const openedRef = useRef<string | null>(null); // prevent double window.open
const [firstPaint, setFirstPaint] = useState(true);
2025-09-30 23:12:52 -04:00
const activeKey = useMemo(() => {
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
return ITEMS.some(i => i.key === t) ? t : ITEMS[0].key;
}, [sp]);
2025-09-30 23:12:52 -04:00
const activeItem = useMemo(
() => ITEMS.find(i => i.key === activeKey) || ITEMS[0],
[activeKey]
);
function setTab(nextKey: string) {
const q = new URLSearchParams(sp.toString());
2025-09-30 23:12:52 -04:00
q.set("t", nextKey);
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
}
2025-09-30 23:12:52 -04:00
// When landing on an external tab, open it once in a new tab.
useEffect(() => {
const item = activeItem;
if (!item) return;
const external = isExternal(item.href);
if (!external) return;
// Avoid duplicate opens in strict mode / re-renders
if (openedRef.current === item.key) return;
openedRef.current = item.key;
// Dont auto-open on the very first paint if you prefer manual click only.
// Set to false to always auto-open, even on initial load of ?t=<external>.
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">
2025-09-30 23:12:52 -04:00
{ITEMS.map((it) => {
const external = 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 (external) {
// Also open immediately on click
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>
{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">
2025-09-30 23:12:52 -04:00
<Panel item={activeItem} />
</div>
</div>
);
}