Fix: Local Source & QOL Animations

This commit is contained in:
Youwes09
2026-04-20 20:59:42 -05:00
parent e41e8011be
commit f5a66ab5d1
13 changed files with 190 additions and 61 deletions
+8
View File
@@ -1,3 +1,11 @@
export const GET_LOCAL_MANGA = `
query GetLocalManga {
mangas(condition: { sourceId: "0" }) {
nodes { id title thumbnailUrl inLibrary }
}
}
`;
export const GET_EXTENSIONS = ` export const GET_EXTENSIONS = `
query GetExtensions { query GetExtensions {
extensions { extensions {
+42 -9
View File
@@ -19,6 +19,29 @@
import TagTab from "./TagTab.svelte"; import TagTab from "./TagTab.svelte";
import SourceTab from "./SourceTab.svelte"; import SourceTab from "./SourceTab.svelte";
const anims = $derived(store.settings.qolAnimations ?? true);
const TABS = ["keyword", "tag", "source"] as const;
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
if (!active) return;
const containerLeft = tabsEl.getBoundingClientRect().left;
tabIndicator = {
left: active.getBoundingClientRect().left - containerLeft,
width: active.offsetWidth,
};
}
$effect(() => {
tab; // reactive on tab change
if (anims) requestAnimationFrame(updateIndicator);
});
const SEARCH_PAGES = 3; const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200; const SEARCH_LIMIT = 200;
const SEARCH_BATCH = 20; const SEARCH_BATCH = 20;
@@ -40,6 +63,7 @@
}); });
let allSources: Source[] = $state([]); let allSources: Source[] = $state([]);
let localSource: Source | null = $state(null);
let loadingSources = $state(false); let loadingSources = $state(false);
const preferredLang = store.settings?.preferredExtensionLang ?? "en"; const preferredLang = store.settings?.preferredExtensionLang ?? "en";
@@ -49,7 +73,9 @@
loadingSources = true; loadingSources = true;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => { .then((d) => {
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0"); const nodes = d.sources.nodes;
localSource = nodes.find((src: Source) => src.id === "0") ?? null;
allSources = nodes.filter((src: Source) => src.id !== "0");
startSourceCacheBuild(); startSourceCacheBuild();
popularStart(allSources); popularStart(allSources);
}) })
@@ -230,10 +256,14 @@
}); });
</script> </script>
<div class="root"> <div class="root anim-fade-in">
<div class="header"> <div class="header">
<h1 class="heading">Search</h1> <span class="heading">Search</span>
<div class="tabs">
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}> <button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "keyword"; }}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/> <path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
@@ -281,6 +311,7 @@
{allSources} {allSources}
{availableLangs} {availableLangs}
{loadingSources} {loadingSources}
{localSource}
onPreview={setPreviewManga} onPreview={setPreviewManga}
/> />
{/if} {/if}
@@ -288,11 +319,13 @@
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); gap: var(--sp-3); } .header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; gap: var(--sp-1); } .tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab { display: flex; align-items: center; gap: var(--sp-1); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); } .tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } .tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
.tab:hover { color: var(--text-muted); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); } .tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); } .tabs-anims .tabActive { background: transparent; border-color: transparent; }
.tabActive:hover { color: var(--accent-fg); }
</style> </style>
@@ -11,9 +11,10 @@
allSources: Source[]; allSources: Source[];
availableLangs: string[]; availableLangs: string[];
loadingSources: boolean; loadingSources: boolean;
localSource: Source | null;
onPreview: (m: Manga) => void; onPreview: (m: Manga) => void;
} }
let { allSources, availableLangs, loadingSources, onPreview }: Props = $props(); let { allSources, availableLangs, loadingSources, localSource, onPreview }: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en"; const preferredLang = store.settings?.preferredExtensionLang ?? "en";
@@ -116,6 +117,21 @@
</div> </div>
{:else} {:else}
<div class="splitList"> <div class="splitList">
{#if localSource}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === localSource.id}
onclick={() => srcSelectSource(localSource)}
>
<div class="localSourceIcon">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
</svg>
</div>
<span class="splitItemLabel">Local Source</span>
</button>
<div class="localDivider"></div>
{/if}
{#each src_visibleSources as src (src.id)} {#each src_visibleSources as src (src.id)}
<button <button
class="splitItem splitItemSource" class="splitItem splitItemSource"
@@ -230,6 +246,8 @@
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); } .langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); } .splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; } .splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); } .splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); } .splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); } .splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
@@ -10,6 +10,9 @@
updateCount: number; updateCount: number;
availableLangs: string[]; availableLangs: string[];
langFilter: string | null; langFilter: string | null;
anims: boolean;
tabIndicator: { left: number; width: number };
tabsEl: HTMLDivElement | undefined;
onFilter: (f: Filter) => void; onFilter: (f: Filter) => void;
onSearch: (q: string) => void; onSearch: (q: string) => void;
onLang: (lang: string | null) => void; onLang: (lang: string | null) => void;
@@ -20,6 +23,8 @@
let { let {
filter, search, panel, refreshing, updateCount, filter, search, panel, refreshing, updateCount,
availableLangs, langFilter, availableLangs, langFilter,
anims, tabIndicator,
tabsEl = $bindable(),
onFilter, onSearch, onLang, onPanel, onRefresh, onFilter, onSearch, onLang, onPanel, onRefresh,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -27,7 +32,10 @@
<div class="header"> <div class="header">
<h1 class="heading">Extensions</h1> <h1 class="heading">Extensions</h1>
<div class="tabs"> <div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
{#each FILTERS as f} {#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}> <button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label} {f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
@@ -71,17 +79,19 @@
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.4; } .icon-btn:disabled { opacity: 0.4; }
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); } .icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; } .tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); } .tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); } .tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.tabs-anims .tab.active { background: transparent; border-color: transparent; }
.search-wrap { position: relative; display: flex; align-items: center; } .search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; } .search-wrap :global(.search-icon) { 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 { 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::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); } .lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); } .lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); } .lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } .lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
</style> </style>
@@ -1,16 +1,29 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { CircleNotch, X, Check } from "phosphor-svelte"; import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
import { gql } from "@api/client"; import { gql } from "@api/client";
import { store, addToast } from "@store/state.svelte"; import { store, addToast } from "@store/state.svelte";
import { GET_EXTENSIONS, GET_SETTINGS } from "@api/queries"; import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries";
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations"; import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
import type { Extension } from "@types/index"; import type { Extension } from "@types/index";
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers"; import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
import ExtensionFilters from "./ExtensionFilters.svelte"; import ExtensionFilters from "./ExtensionFilters.svelte";
import ExtensionCard from "./ExtensionCard.svelte"; import ExtensionCard from "./ExtensionCard.svelte";
const anims = $derived(store.settings.qolAnimations ?? true);
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
const containerLeft = tabsEl.getBoundingClientRect().left;
tabIndicator = { left: active.getBoundingClientRect().left - containerLeft, width: active.offsetWidth };
}
let extensions: Extension[] = $state([]); let extensions: Extension[] = $state([]);
let localMangaCount = $state(0);
let loading = $state(true); let loading = $state(true);
let refreshing = $state(false); let refreshing = $state(false);
let filter = $state<Filter>("installed"); let filter = $state<Filter>("installed");
@@ -20,6 +33,8 @@
let expanded = $state(new Set<string>()); let expanded = $state(new Set<string>());
let panel = $state<Panel>(null); let panel = $state<Panel>(null);
$effect(() => { filter; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
let externalUrl = $state(""); let externalUrl = $state("");
let installing = $state(false); let installing = $state(false);
let installError = $state<string | null>(null); let installError = $state<string | null>(null);
@@ -36,6 +51,11 @@
if (d) extensions = d.extensions.nodes; if (d) extensions = d.extensions.nodes;
} }
async function loadLocalManga() {
const d = await gql<{ mangas: { nodes: { id: number }[] } }>(GET_LOCAL_MANGA).catch(console.error);
if (d) localMangaCount = d.mangas.nodes.length;
}
async function fetchFromRepo() { async function fetchFromRepo() {
refreshing = true; refreshing = true;
const d = await gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS) const d = await gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
@@ -128,30 +148,44 @@
expanded = next; expanded = next;
} }
function setFilter(f: Filter) { filter = f; langFilter = null; } function setFilter(f: Filter) {
if (f === filter) return;
filter = f;
langFilter = null;
}
const filtered = $derived(extensions.filter((e) => { const showLocal = $derived(
(filter === "installed" || filter === "all") &&
(search === "" || "local source".includes(search.toLowerCase()))
);
const allGroups = $derived(groupExtensions(extensions, store.settings.preferredExtensionLang));
const groups = $derived(allGroups.filter(({ primary, variants }) => {
const all = [primary, ...variants];
const q = search.toLowerCase(); const q = search.toLowerCase();
return (e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q)) const matchesSearch = all.some((e) => e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q));
&& matchesFilter(e, filter) const matchesTab = all.some((e) => matchesFilter(e, filter));
&& (langFilter === null || e.lang === langFilter); const matchesLang = langFilter === null || all.some((e) => e.lang === langFilter);
return matchesSearch && matchesTab && matchesLang;
})); }));
const availableLangs = $derived( const availableLangs = $derived(
[...new Set(extensions.filter((e) => matchesFilter(e, filter)).map((e) => e.lang))].sort() [...new Set(extensions.filter((e) => matchesFilter(e, filter)).map((e) => e.lang))].sort()
); );
const groups = $derived(groupExtensions(filtered, store.settings.preferredExtensionLang));
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length); const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); }); $effect(() => { untrack(() => { loadLocalManga(); fetchFromRepo().finally(() => { loading = false; }); }); });
function focusOnMount(node: HTMLElement) { node.focus(); } function focusOnMount(node: HTMLElement) { node.focus(); }
</script> </script>
<div class="root"> <div class="root anim-fade-in">
<ExtensionFilters <ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter} {filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
{anims} {tabIndicator}
bind:tabsEl
onFilter={setFilter} onFilter={setFilter}
onSearch={(q) => search = q} onSearch={(q) => search = q}
onLang={(l) => langFilter = l} onLang={(l) => langFilter = l}
@@ -160,7 +194,7 @@
/> />
{#if panel === "apk"} {#if panel === "apk"}
<div class="ext-panel"> <div class="ext-panel anim-fade-in">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Install from APK URL</span> <span class="panel-title">Install from APK URL</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button> <button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
@@ -186,7 +220,7 @@
{/if} {/if}
{#if panel === "repos"} {#if panel === "repos"}
<div class="ext-panel"> <div class="ext-panel anim-fade-in">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">Extension Repositories</span> <span class="panel-title">Extension Repositories</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button> <button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
@@ -227,10 +261,18 @@
{#if loading} {#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div> <div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else if groups.length === 0}
<div class="empty">No extensions found.</div>
{:else} {:else}
<div class="list"> <div class="list">
{#if showLocal}
<div class="local-row">
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
<div class="info">
<span class="name">Local Source</span>
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
</div>
<span class="local-badge">Built-in</span>
</div>
{/if}
{#each groups as { base, primary, variants }} {#each groups as { base, primary, variants }}
<ExtensionCard <ExtensionCard
{base} {primary} {variants} {working} {base} {primary} {variants} {working}
@@ -239,17 +281,20 @@
onMutate={mutate} onMutate={mutate}
/> />
{/each} {/each}
{#if !showLocal && groups.length === 0}
<div class="empty" style="flex:1">No extensions found.</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; } .list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.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); } .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); }
.icon-btn { 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); } .icon-btn { 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); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; } .ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; }
.panel-header { display: flex; align-items: center; justify-content: space-between; } .panel-header { display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); } .panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; } .panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
@@ -269,4 +314,11 @@
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); } .repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); } .repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
.local-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); margin-bottom: 1px; }
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.local-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; }
</style> </style>
@@ -152,7 +152,7 @@
<SortAscending size={15} weight="bold" /> <SortAscending size={15} weight="bold" />
</button> </button>
{#if sortPanelOpen} {#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu"> <div class="dropdown-panel sort-panel anim-fade-in" role="menu">
<div class="panel-header"> <div class="panel-header">
<span class="panel-heading">Sort</span> <span class="panel-heading">Sort</span>
</div> </div>
@@ -221,7 +221,7 @@
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); } .refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; } .refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
.sort-panel-wrap { position: relative; } .sort-panel-wrap { position: relative; }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; } .dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; } .panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); } .panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); } .panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
@@ -237,5 +237,4 @@
.panel-check-on { background: var(--accent); border-color: var(--accent); } .panel-check-on { background: var(--accent); border-color: var(--accent); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; } .dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
:global(.sort-caret) { flex-shrink: 0; } :global(.sort-caret) { flex-shrink: 0; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
@@ -618,7 +618,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-2); gap: var(--sp-2);
border-bottom: 1px solid var(--border-dim);
} }
.s-storage-wrap:last-child { border-bottom: none; }
.s-storage-header { .s-storage-header {
display: flex; display: flex;
@@ -146,7 +146,17 @@
async function browseDownloadsFolder() { async function browseDownloadsFolder() {
const picked = await invoke<string | null>("pick_downloads_folder"); const picked = await invoke<string | null>("pick_downloads_folder");
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; } if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths(); }
}
async function browseLocalSourceFolder() {
const picked = await invoke<string | null>("pick_downloads_folder");
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths(); }
}
async function browseExtraScanDir() {
const picked = await invoke<string | null>("pick_downloads_folder");
if (picked) { newScanDir = picked; addExtraScanDir(); }
} }
function addExtraScanDir() { function addExtraScanDir() {
@@ -414,9 +424,11 @@
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? "Failed" }; } catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? "Failed" }; }
}}>Create</button> }}>Create</button>
{/if} {/if}
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}> {#if downloadsPathInput.trim() !== confirmedDownloadsPath}
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"} <button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
</button> {pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -465,6 +477,9 @@
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false" bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && savePaths()} onkeydown={(e) => e.key === "Enter" && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} /> oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
{/if}
{#if pathsFieldError.loc && !isExternalServer} {#if pathsFieldError.loc && !isExternalServer}
<button class="s-btn" onclick={async () => { <button class="s-btn" onclick={async () => {
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined }; } try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined }; }
@@ -494,16 +509,13 @@
<div class="s-btn-row"> <div class="s-btn-row">
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false" <input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && addExtraScanDir()} /> onkeydown={(e) => e.key === "Enter" && addExtraScanDir()} />
<button class="s-btn" onclick={addExtraScanDir} disabled={!newScanDir.trim() || extraScanDirs.includes(newScanDir.trim())}>Add</button> {#if !isExternalServer}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if}
</div> </div>
</div> </div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>
+3 -4
View File
@@ -16,7 +16,7 @@
{#if boot.unsupportedMode} {#if boot.unsupportedMode}
<div class="auth-overlay"> <div class="auth-overlay">
<div class="auth-card"> <div class="auth-card anim-scale-in">
<img src={logoUrl} alt="Moku" class="auth-logo" /> <img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p> <p class="auth-title">moku</p>
<span class="auth-mode-badge auth-mode-badge--warn">{ <span class="auth-mode-badge auth-mode-badge--warn">{
@@ -35,7 +35,7 @@
</div> </div>
{:else if boot.loginRequired} {:else if boot.loginRequired}
<div class="auth-overlay"> <div class="auth-overlay">
<div class="auth-card"> <div class="auth-card anim-scale-in">
<img src={logoUrl} alt="Moku" class="auth-logo" /> <img src={logoUrl} alt="Moku" class="auth-logo" />
<p class="auth-title">moku</p> <p class="auth-title">moku</p>
<span class="auth-mode-badge">Basic Auth</span> <span class="auth-mode-badge">Basic Auth</span>
@@ -62,8 +62,7 @@
<style> <style>
.auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; } .auth-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); animation: authIn 0.28s cubic-bezier(0.16,1,0.3,1) both; text-align: center; } .auth-card { pointer-events: auto; width: min(280px, calc(100vw - 48px)); background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-6) var(--sp-5); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 32px 80px rgba(0,0,0,0.75); text-align: center; }
@keyframes authIn { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
.auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; } .auth-logo { width: 56px; height: 56px; border-radius: 14px; display: block; }
.auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; } .auth-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: -6px 0 0; user-select: none; }
-2
View File
@@ -42,8 +42,6 @@
</div> </div>
<style> <style>
:global(*, *::before, *::after) { scrollbar-width: none; }
:global(*::-webkit-scrollbar) { display: none; }
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; } .frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
.shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); min-height: 0; min-width: 0; } .shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); min-height: 0; min-width: 0; }
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; } .main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
+2 -3
View File
@@ -87,7 +87,7 @@
} }
</script> </script>
<div class="root"> <div class="root anim-fade-in">
<div class="header"> <div class="header">
<span class="heading">History</span> <span class="heading">History</span>
<div class="header-right"> <div class="header-right">
@@ -197,7 +197,7 @@
</div> </div>
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); 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; } .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; }
@@ -256,5 +256,4 @@
.empty-text { font-size: var(--text-base); color: var(--text-muted); } .empty-text { font-size: var(--text-base); color: var(--text-muted); }
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); } .empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
+2 -3
View File
@@ -416,7 +416,7 @@
<div class="bottom-area" style="z-index:1"> <div class="bottom-area" style="z-index:1">
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}> <div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if failed || notConfigured} {#if failed || notConfigured}
<div class="error-box"> <div class="error-box anim-fade-up">
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p> <p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
<div class="error-actions"> <div class="error-actions">
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button> <button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
@@ -455,14 +455,13 @@
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } } @keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } } @keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } } @keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } } @keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; } .logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; } .logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; } .hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; } .error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; }
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; } .error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
.error-actions { display: flex; gap: 6px; } .error-actions { display: flex; gap: 6px; }
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; } .err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }