Feat: BulkAutomationPanel & Z-Index Issue (#39 & #44)

This commit is contained in:
Youwes09
2026-04-23 16:03:36 -05:00
parent b12ff4cbaa
commit bb7256c4f8
4 changed files with 325 additions and 52 deletions
+22 -7
View File
@@ -21,9 +21,10 @@
import type { Manga, Category, Chapter } from "@types"; import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte"; import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
import LibraryToolbar from "./LibraryToolbar.svelte"; import LibraryToolbar from "./LibraryToolbar.svelte";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import BulkAutomationPanel from "../panels/BulkAutomationPanel.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte"; import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
@@ -48,10 +49,11 @@
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 }); let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
let selectedIds: Set<number> = $state(new Set()); let selectedIds: Set<number> = $state(new Set());
let selectMode: boolean = $state(false); let selectMode: boolean = $state(false);
let bulkWorking: boolean = $state(false); let bulkWorking: boolean = $state(false);
let bulkMoveOpen: boolean = $state(false); let bulkMoveOpen: boolean = $state(false);
let bulkAutomateOpen: boolean = $state(false);
let sortPanelOpen: boolean = $state(false); let sortPanelOpen: boolean = $state(false);
let filterPanelOpen: boolean = $state(false); let filterPanelOpen: boolean = $state(false);
@@ -308,6 +310,11 @@
finally { bulkWorking = false; exitSelectMode(); } finally { bulkWorking = false; exitSelectMode(); }
} }
function bulkAutomate() {
if (selectedIds.size === 0) return;
bulkAutomateOpen = true;
}
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); } function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
async function openMangaFolder(m: Manga) { async function openMangaFolder(m: Manga) {
@@ -507,6 +514,7 @@
</div> </div>
{:else} {:else}
<LibraryToolbar <LibraryToolbar
onclick={(e: MouseEvent) => { if (selectMode && !(e.target as HTMLElement).closest("button, input")) exitSelectMode(); }}
{tab} {tab}
{tabSortMode} {tabSortMode}
{tabSortDir} {tabSortDir}
@@ -577,6 +585,7 @@
onSelectAll={selectAll} onSelectAll={selectAll}
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }} onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }}
onBulkRemove={bulkRemoveFromLibrary} onBulkRemove={bulkRemoveFromLibrary}
onBulkAutomate={bulkAutomate}
{bulkWorking} {bulkWorking}
{bulkMoveOpen} {bulkMoveOpen}
{visibleCategories} {visibleCategories}
@@ -591,6 +600,12 @@
{#if emptyCtx} {#if emptyCtx}
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} /> <ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
{/if} {/if}
{#if bulkAutomateOpen}
<BulkAutomationPanel
ids={selectedIds}
onClose={() => { bulkAutomateOpen = false; exitSelectMode(); }}
/>
{/if}
<style> <style>
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; } .root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: visible; animation: fadeIn 0.14s ease both; }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Folder, Trash, CheckSquare, X } from "phosphor-svelte"; import { Folder, Trash, CheckSquare, Robot } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte"; import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Category } from "@types"; import type { Manga, Category } from "@types";
@@ -29,6 +29,7 @@
onSelectAll: () => void; onSelectAll: () => void;
onBulkMove: (cat: Category) => void; onBulkMove: (cat: Category) => void;
onBulkRemove: () => void; onBulkRemove: () => void;
onBulkAutomate: () => void;
} }
let { let {
@@ -36,7 +37,7 @@
hasMore, remainingCount, renderLimit, cropCovers, libraryFilter, hasMore, remainingCount, renderLimit, cropCovers, libraryFilter,
bulkWorking, visibleCategories, bulkWorking, visibleCategories,
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave, onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onBulkAutomate,
}: Props = $props(); }: Props = $props();
let bulkMoveOpen: boolean = $state(false); let bulkMoveOpen: boolean = $state(false);
@@ -55,23 +56,19 @@
{#if selectMode} {#if selectMode}
<div class="select-bar"> <div class="select-bar">
<div class="select-bar-left"> <span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-btn sel-cancel" onclick={onExitSelectMode} title="Cancel (Esc)"> <button class="sel-text-btn" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
<X size={13} weight="bold" />
</button>
<span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-btn sel-all" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
</div>
<div class="select-bar-right"> <div class="select-bar-right">
{#if visibleCategories.length} {#if visibleCategories.length}
<div class="bulk-move-wrap"> <div class="bulk-move-wrap">
<button <button
class="sel-btn sel-move" class="sel-action-btn"
disabled={selectedIds.size === 0 || bulkWorking} disabled={selectedIds.size === 0 || bulkWorking}
onclick={() => bulkMoveOpen = !bulkMoveOpen} onclick={() => bulkMoveOpen = !bulkMoveOpen}
> >
<Folder size={13} weight="bold" /> <Folder size={13} weight="bold" />
Move to folder Move
</button> </button>
{#if bulkMoveOpen} {#if bulkMoveOpen}
<div class="bulk-folder-list"> <div class="bulk-folder-list">
@@ -85,7 +82,11 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<button class="sel-btn sel-remove" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}> <button class="sel-action-btn" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkAutomate}>
<Robot size={13} weight="bold" />
Automate
</button>
<button class="sel-action-btn sel-action-danger" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
<Trash size={13} weight="bold" /> <Trash size={13} weight="bold" />
Remove Remove
</button> </button>
@@ -93,7 +94,7 @@
</div> </div>
{/if} {/if}
<div class="content"> <div class="content" onclick={(e) => { if (selectMode && !(e.target as HTMLElement).closest(".card")) onExitSelectMode(); }}>
{#if loading} {#if loading}
<div class="grid"> <div class="grid">
{#each Array(12) as _} {#each Array(12) as _}
@@ -174,22 +175,17 @@
<style> <style>
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; } .content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; } .select-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; position: relative; z-index: 10; }
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); } .select-bar-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; position: relative; }
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; } .sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); } .sel-text-btn { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
.sel-btn { 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); border: 1px solid var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; } .sel-text-btn:hover { color: var(--text-primary); }
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); } .sel-action-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; } .sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-cancel { border-color: transparent; background: transparent; } .sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); } .sel-action-danger:hover:not(:disabled) { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 40%, transparent); background: color-mix(in srgb, var(--color-error, #e05c5c) 8%, transparent); }
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
.sel-all { border-color: transparent; background: transparent; }
.bulk-move-wrap { position: relative; } .bulk-move-wrap { position: relative; }
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; } .bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 9999; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 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); } .bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 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); }
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); } .bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
.grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); } .grid { position: relative; z-index: 1; isolation: isolate; display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
@@ -0,0 +1,272 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs";
import { store } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
let { ids, onClose }: {
ids: Set<number>;
onClose: () => void;
} = $props();
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
let draft: MangaPrefs = $state({ ...DEFAULT_MANGA_PREFS });
const get = <K extends keyof MangaPrefs>(key: K): MangaPrefs[K] => draft[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => { draft = { ...draft, [key]: value }; };
function apply() {
for (const id of ids) {
for (const key of Object.keys(draft) as (keyof MangaPrefs)[]) {
setPref(id, key, draft[key] as any);
}
}
onClose();
}
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Bulk Automation">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">{ids.size} series selected</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={get("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={get("autoDownload")}
onclick={() => set("autoDownload", !get("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("downloadAhead") === opt.value}
onclick={() => set("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("maxKeepChapters") === opt.value}
onclick={() => set("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={get("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={get("deleteOnRead")}
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if get("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("deleteDelayHours") === opt.value}
onclick={() => set("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={get("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={get("pauseUpdates")}
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("refreshInterval") === opt.value}
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
<div class="modal-footer">
<button class="apply-btn" onclick={apply}>Apply to {ids.size} series</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
.modal-footer {
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border-dim); flex-shrink: 0;
}
.apply-btn {
width: 100%; padding: 8px; border-radius: var(--radius-md);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
}
.apply-btn:hover { background: var(--accent-dim); border-color: var(--accent); }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -173,7 +173,7 @@
</div> </div>
<div class="list-header-right"> <div class="list-header-right">
<!-- Jump to chapter -->
<div class="jump-wrap"> <div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter"> <button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" /> <MagnifyingGlass size={14} weight="light" />
@@ -191,7 +191,7 @@
{/if} {/if}
</div> </div>
<!-- Scanlator filter -->
{#if availableScanlators.length > 1} {#if availableScanlators.length > 1}
<div class="scan-filter-wrap"> <div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator"> <button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
@@ -245,12 +245,12 @@
</div> </div>
{/if} {/if}
<!-- Refresh -->
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}> <button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
<!-- Folder picker -->
<div class="fp-wrap" bind:this={folderPickerRef}> <div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}> <button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} /> <FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
@@ -283,7 +283,7 @@
{/if} {/if}
</div> </div>
<!-- Download dropdown -->
{#if chapters.length > 0} {#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}> <div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options"> <button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
@@ -343,7 +343,7 @@
</div> </div>
{/if} {/if}
<!-- Top pagination -->
{#if totalPages > 1} {#if totalPages > 1}
<div class="pagination"> <div class="pagination">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button> <button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button>
@@ -355,7 +355,6 @@
</div> </div>
<style> <style>
/* ─── Header bar ──────────────────────────────────────────── */
.list-header { .list-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
@@ -364,7 +363,6 @@
.list-header-left, .list-header-left,
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); } .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
/* ─── Sort ────────────────────────────────────────────────── */
.sort-btn { .sort-btn {
display: flex; align-items: center; gap: 5px; display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
@@ -390,7 +388,6 @@
.sort-option.active { color: var(--accent-fg); } .sort-option.active { color: var(--accent-fg); }
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); } .sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
/* ─── Icon buttons ────────────────────────────────────────── */
.icon-btn { .icon-btn {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md); width: 28px; height: 28px; border-radius: var(--radius-md);
@@ -402,7 +399,6 @@
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ─── Jump ────────────────────────────────────────────────── */
.jump-wrap { position: relative; } .jump-wrap { position: relative; }
.jump-popover { .jump-popover {
position: absolute; top: calc(100% + 4px); right: 0; width: 220px; position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
@@ -429,7 +425,6 @@
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); } .jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); } .jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
/* ─── Folder picker ───────────────────────────────────────── */
.fp-wrap { position: relative; } .fp-wrap { position: relative; }
.fp-menu { .fp-menu {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
@@ -476,7 +471,6 @@
} }
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); } .fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ─── Download dropdown ───────────────────────────────────── */
.dl-wrap { position: relative; } .dl-wrap { position: relative; }
.dl-dropdown { .dl-dropdown {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
@@ -545,7 +539,6 @@
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; } .dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ─── Pagination (top) ────────────────────────────────────── */
.pagination { display: flex; align-items: center; gap: var(--sp-2); } .pagination { display: flex; align-items: center; gap: var(--sp-2); }
.page-btn { .page-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
@@ -557,7 +550,6 @@
.page-btn:disabled { opacity: 0.3; cursor: default; } .page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ─── Selection toolbar ───────────────────────────────────── */
.sel-count { .sel-count {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
@@ -572,7 +564,6 @@
.sel-action-danger { color: var(--color-error) !important; } .sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; } .sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
/* ─── Scanlator filter ────────────────────────────────────── */
.scan-filter-wrap { position: relative; } .scan-filter-wrap { position: relative; }
.scan-filter-panel { .scan-filter-panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px; position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
@@ -637,6 +628,5 @@
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; } .scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; } .scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
/* ─── Shared animation (used by dropdowns/popovers) ───────── */
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>