UtilitySwitcher bug fixes
This commit is contained in:
parent
6e98075c54
commit
273ca51829
1 changed files with 146 additions and 104 deletions
|
|
@ -1,149 +1,131 @@
|
|||
// 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;
|
||||
href: string;
|
||||
check?: string;
|
||||
/** present only for subdomain links */
|
||||
target?: "_blank";
|
||||
note?: string;
|
||||
icon?: string; // optional icon (public/images/utils/<icon>)
|
||||
href: string; // absolute URL
|
||||
};
|
||||
|
||||
type Tab = "onsite" | "subdomains";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "onsite", label: "On-site" },
|
||||
{ key: "subdomains", label: "Subdomains" },
|
||||
];
|
||||
|
||||
/** Raw catalog (from your old dashboard) */
|
||||
const RAW_ITEMS: Item[] = [
|
||||
// --- on-site (same tab)
|
||||
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",
|
||||
check: "https://makearmy.io/laser-toolkit",
|
||||
},
|
||||
{
|
||||
key: "files",
|
||||
label: "File Server",
|
||||
note: "download from our file explorer",
|
||||
icon: "fs.png",
|
||||
href: "https://makearmy.io/files",
|
||||
check: "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",
|
||||
check: "https://makearmy.io/buying-guide",
|
||||
},
|
||||
{
|
||||
key: "svgnest",
|
||||
label: "SVGnest",
|
||||
note: "automatically nests parts and exports svg",
|
||||
icon: "nest.png",
|
||||
href: "https://makearmy.io/svgnest",
|
||||
check: "https://makearmy.io/svgnest",
|
||||
},
|
||||
{
|
||||
key: "background-remover",
|
||||
label: "BG Remover",
|
||||
note: "advanced open source background remover featuring 10 AI models",
|
||||
note: "open source background remover",
|
||||
icon: "bgrm.png",
|
||||
href: "https://makearmy.io/background-remover",
|
||||
check: "https://makearmy.io/background-remover",
|
||||
},
|
||||
// --- subdomains (new tab)
|
||||
|
||||
// Subdomains (new tab)
|
||||
{
|
||||
key: "picsur",
|
||||
label: "Picsur",
|
||||
note: "Simple Image Host",
|
||||
icon: "picsur.png",
|
||||
href: "https://images.makearmy.io",
|
||||
target: "_blank",
|
||||
check: "https://images.makearmy.io",
|
||||
},
|
||||
{
|
||||
key: "privatebin",
|
||||
label: "PrivateBin",
|
||||
note: "Your encrypted internet clipboard.",
|
||||
note: "Encrypted internet clipboard",
|
||||
icon: "privatebin.png",
|
||||
href: "https://paste.makearmy.io/",
|
||||
target: "_blank",
|
||||
check: "https://paste.makearmy.io/",
|
||||
},
|
||||
{
|
||||
key: "forgejo",
|
||||
label: "Forgejo",
|
||||
note: "git for our community members",
|
||||
icon: "forgejo.png",
|
||||
href: "https://forge.makearmy.io",
|
||||
target: "_blank",
|
||||
check: "https://forge.makearmy.io",
|
||||
},
|
||||
];
|
||||
|
||||
function classify(items: 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 {
|
||||
// Treat malformed URLs as on-site paths
|
||||
onsite.push(it);
|
||||
}
|
||||
function isExternal(urlStr: string) {
|
||||
try {
|
||||
const u = new URL(urlStr);
|
||||
return u.hostname !== "makearmy.io";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return { onsite, subdomains };
|
||||
}
|
||||
|
||||
const { onsite: ONSITE, subdomains: SUBS } = classify(RAW_ITEMS);
|
||||
/** 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 Grid({ items, external }: { items: Item[]; external: boolean }) {
|
||||
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 didn’t open.
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = toOnsitePath(item.href);
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((it) => {
|
||||
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) => {
|
||||
(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 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -151,39 +133,99 @@ function Grid({ items, external }: { items: Item[]; external: boolean }) {
|
|||
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 activeRaw = (sp.get("t") || "onsite").toLowerCase();
|
||||
const active: Tab = (TABS.some((t) => t.key === activeRaw) ? activeRaw : "onsite") as Tab;
|
||||
const activeKey = useMemo(() => {
|
||||
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
|
||||
return ITEMS.some(i => i.key === t) ? t : ITEMS[0].key;
|
||||
}, [sp]);
|
||||
|
||||
function setTab(next: Tab) {
|
||||
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", next);
|
||||
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;
|
||||
|
||||
// Don’t 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">
|
||||
{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>
|
||||
))}
|
||||
{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">
|
||||
{active === "onsite" ? (
|
||||
<Grid items={ONSITE} external={false} />
|
||||
) : (
|
||||
<Grid items={SUBS} external={true} />
|
||||
)}
|
||||
<Panel item={activeItem} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue