import { useEffect, useState, useMemo } from "react"; import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, } from "../../lib/queries"; import { useStore } from "../../store"; import type { Extension } from "../../lib/types"; import s from "./ExtensionList.module.css"; type Filter = "installed" | "available" | "updates" | "all"; // Strip language tag suffix e.g. "MangaDex (EN)" → "MangaDex" function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); } interface ExtGroup { base: string; primary: Extension; variants: Extension[]; // all variants excluding primary } export default function ExtensionList() { const [extensions, setExtensions] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [filter, setFilter] = useState("installed"); const [search, setSearch] = useState(""); const [working, setWorking] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [externalUrl, setExternalUrl] = useState(""); const [showExternal, setShowExternal] = useState(false); const preferredLang = useStore((s) => s.settings.preferredExtensionLang); async function load() { return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS) .then((d) => setExtensions(d.extensions.nodes)) .catch(console.error); } async function fetchFromRepo() { setRefreshing(true); return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS) .then((d) => setExtensions(d.fetchExtensions.extensions)) .catch(console.error) .finally(() => setRefreshing(false)); } const mutate = async (fn: () => Promise, pkgName: string) => { setWorking((p) => new Set(p).add(pkgName)); await fn().catch(console.error); await load(); setWorking((p) => { const n = new Set(p); n.delete(pkgName); return n; }); }; async function installExternal() { if (!externalUrl.trim()) return; await gql(INSTALL_EXTERNAL_EXTENSION, { url: externalUrl.trim() }).catch(console.error); setExternalUrl(""); setShowExternal(false); await load(); } useEffect(() => { fetchFromRepo().finally(() => setLoading(false)); }, []); const filtered = extensions.filter((e) => { const q = search.toLowerCase(); const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q); const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true; return matchSearch && matchFilter; }); // Group by base name. Primary is the preferred/en/first variant. // variants contains only the non-primary ones for the expanded list. const groups = useMemo(() => { const map = new Map(); for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); } return Array.from(map.entries()).map(([base, all]) => { const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0]; const variants = all.filter((v) => v.pkgName !== primary.pkgName); return { base, primary, variants }; }); }, [filtered, preferredLang]); const updateCount = extensions.filter((e) => e.hasUpdate).length; const FILTERS: { id: Filter; label: string }[] = [ { id: "installed", label: "Installed" }, { id: "available", label: "Available" }, { id: "updates", label: updateCount > 0 ? `Updates (${updateCount})` : "Updates" }, { id: "all", label: "All" }, ]; function toggleExpand(base: string) { setExpanded((p) => { const n = new Set(p); n.has(base) ? n.delete(base) : n.add(base); return n; }); } function renderActions(ext: Extension) { if (working.has(ext.pkgName)) return ; if (ext.hasUpdate) return (
); if (ext.isInstalled) return ; return ; } return (

Extensions

{showExternal && (
setExternalUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && installExternal()} autoFocus />
)}
{FILTERS.map((f) => ( ))}
setSearch(e.target.value)} />
{loading ? (
) : groups.length === 0 ? (
No extensions found.
) : (
{groups.map(({ base, primary, variants }) => { const isExpanded = expanded.has(base); const hasVariants = variants.length > 0; return (
{primary.name} { (e.target as HTMLImageElement).style.display = "none"; }} />
{base} {primary.lang.toUpperCase()} {" "}v{primary.versionName}
{primary.hasUpdate && Update} {renderActions(primary)} {hasVariants && ( )}
{isExpanded && hasVariants && (
{variants.map((v) => (
{v.lang.toUpperCase()} {v.name} v{v.versionName} {v.hasUpdate && }
{renderActions(v)}
))}
)}
); })}
)}
); }