2025-09-30 23:05:29 -04:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
2025-10-12 22:24:23 -04:00
|
|
|
import dynamic from "next/dynamic";
|
2025-09-30 23:05:29 -04:00
|
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
2025-09-30 23:08:06 -04:00
|
|
|
type Item = {
|
2026-03-04 21:10:23 -05: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;
|
2026-03-04 21:10:23 -05:00
|
|
|
icon?: string; // optional icon (public/images/utils/<icon>)
|
|
|
|
|
href?: string; // optional absolute URL (used if no component)
|
2025-10-12 22:24:23 -04:00
|
|
|
component?: React.ComponentType<{ embedded?: boolean }>;
|
2025-09-30 23:08:06 -04:00
|
|
|
};
|
|
|
|
|
|
2025-10-12 22:24:23 -04:00
|
|
|
// Lazy-load heavy utilities
|
|
|
|
|
const BackgroundRemoverPanel = dynamic(
|
|
|
|
|
() => import("@/components/utilities/BackgroundRemoverPanel"),
|
|
|
|
|
{ ssr: false }
|
|
|
|
|
);
|
2026-03-04 21:10:23 -05:00
|
|
|
const SVGNestPanel = dynamic(() => import("@/components/utilities/SVGNestPanel"), {
|
|
|
|
|
ssr: false,
|
|
|
|
|
});
|
2025-10-12 22:24:23 -04:00
|
|
|
const LaserToolkitSwitcher = dynamic(
|
|
|
|
|
() => import("@/components/portal/LaserToolkitSwitcher"),
|
|
|
|
|
{ ssr: false }
|
|
|
|
|
);
|
|
|
|
|
// Inline File Server
|
|
|
|
|
const FileBrowserPanel = dynamic(
|
|
|
|
|
() => import("@/components/utilities/files/FileBrowserPanel"),
|
|
|
|
|
{ ssr: false }
|
|
|
|
|
);
|
|
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
const ITEMS: Item[] = [
|
2026-03-04 21:10:23 -05:00
|
|
|
{
|
|
|
|
|
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",
|
|
|
|
|
},
|
2025-09-30 23:05:29 -04:00
|
|
|
];
|
|
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
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;
|
|
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
try {
|
2026-03-04 21:10:23 -05:00
|
|
|
const u = new URL(href);
|
|
|
|
|
if (typeof window === "undefined") return true;
|
|
|
|
|
return u.origin !== window.location.origin;
|
2025-09-30 23:12:52 -04:00
|
|
|
} catch {
|
|
|
|
|
return true;
|
2025-09-30 23:05:29 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
try {
|
2026-03-04 21:10:23 -05:00
|
|
|
const u = new URL(href);
|
|
|
|
|
if (typeof window === "undefined") return href;
|
|
|
|
|
if (u.origin === window.location.origin) {
|
2025-09-30 23:12:52 -04:00
|
|
|
return `${u.pathname}${u.search}${u.hash}`;
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
2026-03-04 21:10:23 -05:00
|
|
|
return href;
|
2025-09-30 23:12:52 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Panel({ item }: { item: Item }) {
|
2025-10-12 22:24:23 -04:00
|
|
|
if (item.component) {
|
|
|
|
|
const Cmp = item.component;
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-3">
|
2025-10-15 20:53:06 -04:00
|
|
|
{/* Removed notes/headers to keep UI clean */}
|
2025-10-12 22:24:23 -04:00
|
|
|
<Cmp embedded />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-30 23:05:29 -04:00
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
const href = item.href || "/";
|
|
|
|
|
const external = isExternalHref(href);
|
|
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
if (external) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2 text-sm">
|
2026-03-04 21:10:23 -05:00
|
|
|
<a
|
|
|
|
|
href={href}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="underline"
|
|
|
|
|
>
|
2025-10-15 20:53:06 -04:00
|
|
|
Open {item.label}
|
2025-09-30 23:12:52 -04:00
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
const src = toSameOriginPath(href);
|
2025-09-30 23:05:29 -04:00
|
|
|
return (
|
2025-09-30 23:12:52 -04:00
|
|
|
<iframe
|
|
|
|
|
key={src}
|
|
|
|
|
src={src}
|
2025-10-15 20:53:06 -04:00
|
|
|
className="w-full"
|
|
|
|
|
style={{ height: "72vh" }}
|
|
|
|
|
// no sandbox; needs drag/drop etc.
|
2025-09-30 23:12:52 -04:00
|
|
|
/>
|
2025-09-30 23:05:29 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function UtilitySwitcher() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const sp = useSearchParams();
|
2025-10-12 22:24:23 -04:00
|
|
|
const openedRef = useRef<string | null>(null);
|
2025-09-30 23:12:52 -04:00
|
|
|
const [firstPaint, setFirstPaint] = useState(true);
|
2025-09-30 23:05:29 -04:00
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
const activeKey = useMemo(() => {
|
|
|
|
|
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
|
2026-03-04 21:10:23 -05:00
|
|
|
return ITEMS.some((i) => i.key === t) ? t : ITEMS[0].key;
|
2025-09-30 23:12:52 -04:00
|
|
|
}, [sp]);
|
2025-09-30 23:05:29 -04:00
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
const activeItem = useMemo(
|
2026-03-04 21:10:23 -05:00
|
|
|
() => ITEMS.find((i) => i.key === activeKey) || ITEMS[0],
|
2025-09-30 23:12:52 -04:00
|
|
|
[activeKey]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function setTab(nextKey: string) {
|
2025-09-30 23:05:29 -04:00
|
|
|
const q = new URLSearchParams(sp.toString());
|
2025-09-30 23:12:52 -04:00
|
|
|
q.set("t", nextKey);
|
2025-09-30 23:05:29 -04:00
|
|
|
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
/**
|
|
|
|
|
* Optional auto-open behavior for external tools when selected via URL (?t=...).
|
|
|
|
|
* RECOMMENDED: keep this off to avoid popup blockers / surprise tabs.
|
|
|
|
|
*/
|
2025-09-30 23:12:52 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
const item = activeItem;
|
2026-03-04 21:10:23 -05:00
|
|
|
if (!item?.href) return;
|
2025-10-12 22:24:23 -04:00
|
|
|
if (item.component) return;
|
2026-03-04 21:10:23 -05:00
|
|
|
if (!isExternalHref(item.href)) return;
|
2025-09-30 23:12:52 -04:00
|
|
|
|
|
|
|
|
if (openedRef.current === item.key) return;
|
|
|
|
|
openedRef.current = item.key;
|
|
|
|
|
|
2026-03-04 21:10:23 -05:00
|
|
|
const AUTO_OPEN_ON_FIRST_PAINT = false;
|
2025-09-30 23:12:52 -04:00
|
|
|
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
2026-03-04 21:10:23 -05:00
|
|
|
window.open(item.href, "_blank", "noopener,noreferrer");
|
2025-09-30 23:12:52 -04:00
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-03-04 21:10:23 -05:00
|
|
|
}, [activeItem?.key]);
|
2025-09-30 23:12:52 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setFirstPaint(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-30 23:05:29 -04:00
|
|
|
return (
|
|
|
|
|
<div>
|
2025-10-15 20:53:06 -04:00
|
|
|
{/* top buttons unchanged */}
|
2025-09-30 23:05:29 -04:00
|
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
2025-09-30 23:12:52 -04:00
|
|
|
{ITEMS.map((it) => {
|
2025-10-12 22:24:23 -04:00
|
|
|
const isInline = Boolean(it.component);
|
2026-03-04 21:10:23 -05:00
|
|
|
const external = !isInline && isExternalHref(it.href);
|
2025-09-30 23:12:52 -04:00
|
|
|
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
|
|
|
|
const isActive = it.key === activeKey;
|
2026-03-04 21:10:23 -05:00
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={it.key}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setTab(it.key);
|
2025-10-12 22:24:23 -04:00
|
|
|
if (!isInline && external) {
|
|
|
|
|
window.open(it.href!, "_blank", "noopener,noreferrer");
|
2025-09-30 23:12:52 -04:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
|
2026-03-04 21:10:23 -05:00
|
|
|
isActive
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: "hover:bg-muted"
|
2025-09-30 23:12:52 -04:00
|
|
|
)}
|
|
|
|
|
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}
|
2026-03-04 21:10:23 -05:00
|
|
|
|
2025-09-30 23:12:52 -04:00
|
|
|
<span className="truncate">{it.label}</span>
|
2026-03-04 21:10:23 -05:00
|
|
|
|
2025-10-12 22:24:23 -04:00
|
|
|
{!isInline && external && (
|
2025-09-30 23:12:52 -04:00
|
|
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
new tab
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-09-30 23:05:29 -04:00
|
|
|
</div>
|
|
|
|
|
|
2025-10-15 20:53:06 -04:00
|
|
|
{/* ⛔️ removed the old border/padding frame here */}
|
2025-09-30 23:12:52 -04:00
|
|
|
<Panel item={activeItem} />
|
2025-09-30 23:05:29 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|