makearmy-app/components/portal/UtilitySwitcher.tsx

232 lines
6.7 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 { 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; // absolute URL
};
const ITEMS: Item[] = [
// On-site (embed)
{
key: "laser-toolkit",
label: "Laser Toolkit",
note: "convert laser settings, interval and more",
icon: "toolkit.png",
href: "https://makearmy.io/laser-toolkit",
},
{
key: "files",
label: "File Server",
note: "download from our file explorer",
icon: "fs.png",
href: "https://makearmy.io/files",
},
{
key: "buying-guide",
label: "Buying Guide",
note: "reviews and listings for relevant products",
icon: "bg.png",
href: "https://makearmy.io/buying-guide",
},
{
key: "svgnest",
label: "SVGnest",
note: "automatically nests parts and exports svg",
icon: "nest.png",
href: "https://makearmy.io/svgnest",
},
{
key: "background-remover",
label: "BG Remover",
note: "open source background remover",
icon: "bgrm.png",
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) {
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 }) {
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">
<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();
const openedRef = useRef<string | null>(null); // prevent double window.open
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 });
}
// 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">
{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">
<Panel item={activeItem} />
</div>
</div>
);
}