[BETA] Initial Commit (Nix Support Only)

This commit is contained in:
Youwes09
2026-02-20 23:34:10 -06:00
commit 09554c68df
113 changed files with 14400 additions and 0 deletions
@@ -0,0 +1,172 @@
.root {
display: flex; flex-direction: column; height: 100%;
overflow: hidden; animation: fadeIn 0.14s ease both;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
}
.heading {
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.headerActions { display: flex; gap: var(--sp-1); }
.iconBtn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
}
.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.iconBtn:disabled { opacity: 0.4; }
.externalRow {
display: flex; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
}
.externalInput {
flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); padding: 6px var(--sp-3);
color: var(--text-primary); font-size: var(--text-sm); outline: none;
}
.externalInput:focus { border-color: var(--border-focus); }
.installBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 6px 14px; border-radius: var(--radius-md);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); cursor: pointer;
}
.installBtn:hover { filter: brightness(1.1); }
.controls {
display: flex; align-items: center; justify-content: space-between;
padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0;
}
.tabs { display: flex; gap: 2px; }
.tab {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-md); border: none;
background: none; color: var(--text-muted); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
.tabActive { background: var(--accent-muted); color: var(--accent-fg); }
.tabActive:hover { background: var(--accent-muted); color: var(--accent-fg); }
.searchWrap { position: relative; display: flex; align-items: center; }
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 26px;
color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none;
transition: border-color var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.group { display: flex; flex-direction: column; }
.row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 8px var(--sp-3); border-radius: var(--radius-md);
border: 1px solid transparent;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.icon {
width: 32px; height: 32px; border-radius: var(--radius-md);
object-fit: cover; flex-shrink: 0; background: var(--bg-raised);
}
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.meta {
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.langTag {
background: var(--bg-overlay); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 1px 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-muted); letter-spacing: var(--tracking-wider);
}
.nsfwTag {
background: transparent; border: 1px solid var(--color-error);
border-radius: var(--radius-sm); padding: 1px 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--color-error); letter-spacing: var(--tracking-wider);
}
.updateBadge {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); border-radius: var(--radius-sm);
padding: 2px 6px; flex-shrink: 0;
}
.updateBadgeSmall {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--accent-fg); flex-shrink: 0;
}
.rowActions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.actionBtn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-md);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0;
transition: filter var(--t-base);
}
.actionBtn:hover { filter: brightness(1.1); }
.actionBtnDim {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-md);
background: none; color: var(--text-faint);
border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.actionBtnDim:hover { color: var(--color-error); border-color: var(--color-error); }
.expandBtn {
display: flex; align-items: center; gap: 3px;
padding: 4px 6px; border-radius: var(--radius-sm);
color: var(--text-faint); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base);
}
.expandBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.expandCount {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
}
.variants {
display: flex; flex-direction: column; gap: 1px;
margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3));
padding-left: var(--sp-3);
border-left: 1px solid var(--border-dim);
animation: fadeIn 0.1s ease both;
}
.variantRow {
display: flex; align-items: center; gap: var(--sp-2);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.variantRow:hover { background: var(--bg-raised); }
.variantName {
flex: 1; font-size: var(--text-sm); color: var(--text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.variantVersion {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.variantActions { flex-shrink: 0; }
.empty {
display: flex; align-items: center; justify-content: center;
flex: 1; color: var(--text-faint);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
}
+225
View File
@@ -0,0 +1,225 @@
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<Extension[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<Filter>("installed");
const [search, setSearch] = useState("");
const [working, setWorking] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(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<unknown>, 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<ExtGroup[]>(() => {
const map = new Map<string, Extension[]>();
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 <CircleNotch size={14} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />;
if (ext.hasUpdate) return (
<div className={s.rowActions}>
<button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, update: true }), ext.pkgName)}>Update</button>
<button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>
</div>
);
if (ext.isInstalled)
return <button className={s.actionBtnDim} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, uninstall: true }), ext.pkgName)}>Remove</button>;
return <button className={s.actionBtn} onClick={() => mutate(() => gql(UPDATE_EXTENSION, { id: ext.pkgName, install: true }), ext.pkgName)}>Install</button>;
}
return (
<div className={s.root}>
<div className={s.header}>
<h1 className={s.heading}>Extensions</h1>
<div className={s.headerActions}>
<button className={s.iconBtn} onClick={() => setShowExternal(!showExternal)} title="Install from URL">
<Plus size={14} weight="light" />
</button>
<button className={s.iconBtn} onClick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" className={refreshing ? "anim-spin" : ""} />
</button>
</div>
</div>
{showExternal && (
<div className={s.externalRow}>
<input className={s.externalInput} placeholder="APK URL"
value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && installExternal()} autoFocus />
<button className={s.installBtn} onClick={installExternal}>Install</button>
</div>
)}
<div className={s.controls}>
<div className={s.tabs}>
{FILTERS.map((f) => (
<button key={f.id} onClick={() => setFilter(f.id)}
className={[s.tab, filter === f.id ? s.tabActive : ""].join(" ").trim()}>
{f.label}
</button>
))}
</div>
<div className={s.searchWrap}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input className={s.search} placeholder="Search"
value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
{loading ? (
<div className={s.empty}>
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
</div>
) : groups.length === 0 ? (
<div className={s.empty}>No extensions found.</div>
) : (
<div className={s.list}>
{groups.map(({ base, primary, variants }) => {
const isExpanded = expanded.has(base);
const hasVariants = variants.length > 0;
return (
<div key={base} className={s.group}>
<div className={s.row}>
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} className={s.icon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div className={s.info}>
<span className={s.name}>{base}</span>
<span className={s.meta}>
<span className={s.langTag}>{primary.lang.toUpperCase()}</span>
{" "}v{primary.versionName}
</span>
</div>
{primary.hasUpdate && <span className={s.updateBadge}>Update</span>}
{renderActions(primary)}
{hasVariants && (
<button className={s.expandBtn} onClick={() => toggleExpand(base)}
title={`${variants.length + 1} languages`}>
{isExpanded
? <CaretDown size={12} weight="light" />
: <CaretRight size={12} weight="light" />}
<span className={s.expandCount}>{variants.length + 1}</span>
</button>
)}
</div>
{isExpanded && hasVariants && (
<div className={s.variants}>
{variants.map((v) => (
<div key={v.pkgName} className={s.variantRow}>
<span className={s.langTag}>{v.lang.toUpperCase()}</span>
<span className={s.variantName}>{v.name}</span>
<span className={s.variantVersion}>v{v.versionName}</span>
{v.hasUpdate && <span className={s.updateBadgeSmall}></span>}
<div className={s.variantActions}>{renderActions(v)}</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}