mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Fix: Library FolderSetting Re-Vamp
This commit is contained in:
@@ -85,10 +85,40 @@
|
|||||||
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
|
||||||
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
|
||||||
|
|
||||||
|
const BUILTIN_TABS = ["library", "downloaded"] as const;
|
||||||
|
|
||||||
|
const completedCatId = $derived(
|
||||||
|
store.categories.find(c => c.name === COMPLETED_NAME && c.id !== 0)?.id ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
const allTabIds = $derived((() => {
|
||||||
|
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
|
||||||
|
const pinned = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
|
const known = new Set([...BUILTIN_TABS, ...catIds]);
|
||||||
|
const ordered = [...pinned.filter(id => known.has(id))];
|
||||||
|
const inOrder = new Set(ordered);
|
||||||
|
for (const id of [...BUILTIN_TABS, ...catIds]) {
|
||||||
|
if (!inOrder.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const hiddenTabs = $derived(new Set(store.settings.hiddenLibraryTabs ?? []));
|
||||||
|
|
||||||
|
const visibleTabIds = $derived(allTabIds.filter(id => !hiddenTabs.has(id)));
|
||||||
|
|
||||||
|
const virtualTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id === "library" || id === "downloaded" || (completedCatId !== null && id === String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
|
const folderTabIds = $derived(visibleTabIds.filter(id =>
|
||||||
|
id !== "library" && id !== "downloaded" && (completedCatId === null || id !== String(completedCatId))
|
||||||
|
));
|
||||||
|
|
||||||
const visibleCategories = $derived((() => {
|
const visibleCategories = $derived((() => {
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
||||||
return store.categories
|
return store.categories
|
||||||
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
|
.filter(c => c.id !== 0 && !hiddenTabs.has(String(c.id)))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.id === defaultId) return -1;
|
if (a.id === defaultId) return -1;
|
||||||
if (b.id === defaultId) return 1;
|
if (b.id === defaultId) return 1;
|
||||||
@@ -172,7 +202,7 @@
|
|||||||
|
|
||||||
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
|
||||||
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
|
||||||
let prevTab = tab;
|
let prevTab = $state(tab);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextTab = tab;
|
const nextTab = tab;
|
||||||
if (scrollEl && nextTab !== prevTab) {
|
if (scrollEl && nextTab !== prevTab) {
|
||||||
@@ -605,6 +635,10 @@
|
|||||||
{hasActiveFilters}
|
{hasActiveFilters}
|
||||||
{anims}
|
{anims}
|
||||||
{visibleCategories}
|
{visibleCategories}
|
||||||
|
{visibleTabIds}
|
||||||
|
{virtualTabIds}
|
||||||
|
{folderTabIds}
|
||||||
|
{completedCatId}
|
||||||
{counts}
|
{counts}
|
||||||
{search}
|
{search}
|
||||||
{refreshing}
|
{refreshing}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X,
|
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||||
} from "phosphor-svelte";
|
} from "phosphor-svelte";
|
||||||
import LibraryFilters from "./LibraryFilters.svelte";
|
import LibraryFilters from "./LibraryFilters.svelte";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
@@ -16,6 +16,10 @@
|
|||||||
hasActiveFilters: boolean;
|
hasActiveFilters: boolean;
|
||||||
anims: boolean;
|
anims: boolean;
|
||||||
visibleCategories: Category[];
|
visibleCategories: Category[];
|
||||||
|
visibleTabIds: string[];
|
||||||
|
virtualTabIds: string[];
|
||||||
|
folderTabIds: string[];
|
||||||
|
completedCatId: number | null;
|
||||||
counts: Record<string, number>;
|
counts: Record<string, number>;
|
||||||
search: string;
|
search: string;
|
||||||
activeDragKind: "tab" | null;
|
activeDragKind: "tab" | null;
|
||||||
@@ -44,8 +48,8 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||||
anims, visibleCategories, counts, search, refreshing,
|
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||||
refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
|
counts, search, refreshing, refreshProgress, refreshDone, activeDragKind, dragInsertIdx,
|
||||||
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||||
tabsEl = $bindable(),
|
tabsEl = $bindable(),
|
||||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||||
@@ -67,53 +71,43 @@
|
|||||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeCatId = $derived(
|
|
||||||
tab !== "library" && tab !== "downloaded" ? Number(tab) : null
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Library</span>
|
<span class="heading">Library</span>
|
||||||
|
|
||||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
|
||||||
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
|
{#each visibleTabIds as id, idx}
|
||||||
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
|
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||||
{#if f === "library"}<Books size={11} weight="bold" />
|
{#if id === "library" || id === "downloaded" || cat}
|
||||||
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
{#if cat && dragInsertIdx === idx && activeDragKind === "tab"}
|
||||||
{label}
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
<span class="tab-count">{counts[f] ?? 0}</span>
|
{/if}
|
||||||
</button>
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={tab === id}
|
||||||
|
class:tab-dragging={cat && dragTabId === cat.id}
|
||||||
|
draggable={!!cat && id !== String(completedCatId)}
|
||||||
|
onclick={() => onTabChange(id)}
|
||||||
|
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined}
|
||||||
|
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined}
|
||||||
|
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined}
|
||||||
|
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined}
|
||||||
|
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined}
|
||||||
|
>
|
||||||
|
{#if id === "library"}<Books size={11} weight="bold" />
|
||||||
|
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||||
|
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
|
||||||
|
{:else if cat}<Folder size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
|
||||||
|
<span class="tab-count">{counts[id] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
{#if cat && id !== String(completedCatId) && dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleTabIds.length - 1}
|
||||||
|
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if visibleCategories.length > 0}
|
|
||||||
<div class="tab-separator" aria-hidden="true"></div>
|
|
||||||
<div class="tabs-scroll">
|
|
||||||
{#each visibleCategories as cat, idx}
|
|
||||||
{#if dragInsertIdx === idx && activeDragKind === "tab"}
|
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
class:active={tab === String(cat.id)}
|
|
||||||
class:tab-dragging={dragTabId === cat.id}
|
|
||||||
draggable="true"
|
|
||||||
onclick={() => onTabChange(String(cat.id))}
|
|
||||||
ondragstart={(e) => onTabDragStart(e, cat)}
|
|
||||||
ondragover={(e) => onTabDragOver(e, cat, idx)}
|
|
||||||
ondragleave={onTabDragLeave}
|
|
||||||
ondrop={(e) => onTabDrop(e, cat)}
|
|
||||||
ondragend={onTabDragEnd}
|
|
||||||
>
|
|
||||||
<Folder size={11} weight="bold" />
|
|
||||||
{cat.name}
|
|
||||||
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
|
|
||||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -204,10 +198,8 @@
|
|||||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; 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; }
|
.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; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow: hidden; }
|
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||||
.tabs-scroll { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; min-width: 0; flex-shrink: 1; }
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
.tabs-scroll::-webkit-scrollbar { display: none; }
|
|
||||||
.tab-separator { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 2px; }
|
|
||||||
.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); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
.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); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical } from "phosphor-svelte";
|
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_CATEGORIES } from "@api/queries/manga";
|
import { GET_CATEGORIES } from "@api/queries/manga";
|
||||||
import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
|
import { CREATE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORIES, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
|
||||||
import type { Category } from "@types";
|
import type { Category } from "@types";
|
||||||
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte";
|
import { store, updateSettings, setCategories } from "@store/state.svelte";
|
||||||
|
|
||||||
|
const completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
|
||||||
|
const completedId = $derived(completedCat ? String(completedCat.id) : null);
|
||||||
|
const sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
|
||||||
|
const orderedCatIds = $derived.by(() => {
|
||||||
|
const order = store.settings.libraryPinnedTabOrder ?? [];
|
||||||
|
const known = new Set(sortedCatIds);
|
||||||
|
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))];
|
||||||
|
});
|
||||||
|
|
||||||
let catsLoading = $state(false);
|
let catsLoading = $state(false);
|
||||||
let catsError = $state<string | null>(null);
|
let catsError = $state<string | null>(null);
|
||||||
@@ -16,6 +25,15 @@
|
|||||||
let dragOverId = $state<number | null>(null);
|
let dragOverId = $state<number | null>(null);
|
||||||
let dropPosition = $state<"above" | "below" | null>(null);
|
let dropPosition = $state<"above" | "below" | null>(null);
|
||||||
|
|
||||||
|
function isHidden(id: string) {
|
||||||
|
return (store.settings.hiddenLibraryTabs ?? []).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(id: string) {
|
||||||
|
const current = store.settings.hiddenLibraryTabs ?? [];
|
||||||
|
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
catsLoading = true; catsError = null;
|
catsLoading = true; catsError = null;
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +102,10 @@
|
|||||||
const [moved] = reordered.splice(fromIdx, 1);
|
const [moved] = reordered.splice(fromIdx, 1);
|
||||||
reordered.splice(toIdx, 0, moved);
|
reordered.splice(toIdx, 0, moved);
|
||||||
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
|
||||||
|
|
||||||
|
const catIds = reordered.map(c => String(c.id));
|
||||||
|
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
|
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 });
|
||||||
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
|
||||||
@@ -136,9 +158,115 @@
|
|||||||
<div class="s-row">
|
<div class="s-row">
|
||||||
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if catsError}
|
{#if catsError}
|
||||||
<div class="s-banner s-banner-error">{catsError}</div>
|
<div class="s-banner s-banner-error">{catsError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if catsLoading}
|
||||||
|
<p class="s-empty">Loading folders…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">Saved</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden("library")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><DownloadSimple size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">Downloaded</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden("downloaded")} onclick={() => toggleHidden("downloaded")} title={isHidden("downloaded") ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden("downloaded")}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if completedCat}
|
||||||
|
<div class="s-folder-row s-folder-row-static">
|
||||||
|
<span class="s-folder-icon-static"><CheckSquare size={14} weight="light" /></span>
|
||||||
|
<span class="s-folder-name s-folder-name-static">{completedCat.name}</span>
|
||||||
|
<span class="s-folder-count">{completedCat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
<span class="s-folder-badge">built-in</span>
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:muted={isHidden(String(completedCat.id))} onclick={() => toggleHidden(String(completedCat!.id))} title={isHidden(String(completedCat.id)) ? "Show tab in library" : "Hide tab from library"}>
|
||||||
|
{#if isHidden(String(completedCat.id))}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="s-folder-divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
||||||
|
{#each orderedCatIds.filter(id => id !== completedId) as id}
|
||||||
|
{@const cat = store.categories.find(c => String(c.id) === id) ?? null}
|
||||||
|
{@const hidden = isHidden(id)}
|
||||||
|
{#if cat}
|
||||||
|
<div
|
||||||
|
class="s-folder-row"
|
||||||
|
class:dragging={dragId === cat.id}
|
||||||
|
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
||||||
|
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
||||||
|
ondragover={(e) => onDragOver(e, cat.id)}
|
||||||
|
ondrop={(e) => onDrop(e, cat.id)}
|
||||||
|
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
||||||
|
>
|
||||||
|
{#if editingId === cat.id}
|
||||||
|
<input class="s-input full" bind:value={editingName}
|
||||||
|
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
||||||
|
onblur={commitEdit} use:focusInput />
|
||||||
|
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||||
|
{:else}
|
||||||
|
<div class="s-folder-identity" draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, cat.id)}
|
||||||
|
ondragend={onDragEnd}>
|
||||||
|
<span class="s-folder-icon">
|
||||||
|
<FolderSimple size={14} weight="light" />
|
||||||
|
<DotsSixVertical size={14} weight="bold" />
|
||||||
|
</span>
|
||||||
|
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||||
|
|
||||||
|
<div class="s-folder-actions">
|
||||||
|
<button class="s-btn-icon" class:active={(store.settings.defaultLibraryCategoryId ?? null) === cat.id} onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })} title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
|
||||||
|
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show in library" : "Hide from library"}>
|
||||||
|
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInUpdate !== false} class:inactive={cat.includeInUpdate === false} onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")} title={cat.includeInUpdate !== false ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
||||||
|
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon" class:active={cat.includeInDownload !== false} class:inactive={cat.includeInDownload === false} onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")} title={cat.includeInDownload !== false ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
||||||
|
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? "bold" : "light"} />
|
||||||
|
</button>
|
||||||
|
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||||
|
<Trash size={12} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.categories.filter(c => c.id !== 0 && c.name !== "Completed").length === 0}
|
||||||
|
<p class="s-empty">No custom folders yet. Create one below.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="s-folder-create">
|
<div class="s-folder-create">
|
||||||
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
||||||
onkeydown={(e) => e.key === "Enter" && createFolder()} />
|
onkeydown={(e) => e.key === "Enter" && createFolder()} />
|
||||||
@@ -146,97 +274,6 @@
|
|||||||
<Plus size={13} weight="bold" /> Create
|
<Plus size={13} weight="bold" /> Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if catsLoading}
|
|
||||||
<p class="s-empty">Loading folders…</p>
|
|
||||||
{:else if store.categories.filter(c => c.id !== 0).length === 0}
|
|
||||||
<p class="s-empty">No folders yet. Create one above.</p>
|
|
||||||
{:else}
|
|
||||||
{@const displayCats = store.categories
|
|
||||||
.filter(c => c.id !== 0)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
|
|
||||||
if (a.id === defaultId) return -1;
|
|
||||||
if (b.id === defaultId) return 1;
|
|
||||||
return a.order - b.order;
|
|
||||||
})}
|
|
||||||
<div class="s-folder-list" class:is-dragging={dragId !== null}>
|
|
||||||
{#each displayCats as cat}
|
|
||||||
<div
|
|
||||||
class="s-folder-row"
|
|
||||||
class:dragging={dragId === cat.id}
|
|
||||||
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"}
|
|
||||||
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"}
|
|
||||||
ondragover={(e) => onDragOver(e, cat.id)}
|
|
||||||
ondrop={(e) => onDrop(e, cat.id)}
|
|
||||||
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }}
|
|
||||||
>
|
|
||||||
{#if editingId === cat.id}
|
|
||||||
<input class="s-input full" bind:value={editingName}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") { editingId = null; } }}
|
|
||||||
onblur={commitEdit} use:focusInput />
|
|
||||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
|
||||||
{:else}
|
|
||||||
{@const isDefault = (store.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
|
||||||
{@const isHidden = (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}
|
|
||||||
{@const inUpdate = cat.includeInUpdate !== false}
|
|
||||||
{@const inDl = cat.includeInDownload !== false}
|
|
||||||
|
|
||||||
<div class="s-folder-identity" draggable="true"
|
|
||||||
ondragstart={(e) => onDragStart(e, cat.id)}
|
|
||||||
ondragend={onDragEnd}>
|
|
||||||
<span class="s-folder-icon">
|
|
||||||
<FolderSimple size={14} weight="light" />
|
|
||||||
<DotsSixVertical size={14} weight="bold" />
|
|
||||||
</span>
|
|
||||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name); }} title="Click to rename">
|
|
||||||
{cat.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
|
||||||
|
|
||||||
<div class="s-folder-actions">
|
|
||||||
<button class="s-btn-icon" class:active={isDefault}
|
|
||||||
onclick={() => updateSettings({ defaultLibraryCategoryId: isDefault ? null : cat.id })}
|
|
||||||
title={isDefault ? "Remove as default folder" : "Set as default folder"}>
|
|
||||||
<Star size={13} weight={isDefault ? "fill" : "light"} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="s-btn-icon" class:muted={isHidden}
|
|
||||||
onclick={() => toggleHiddenCategory(cat.id)}
|
|
||||||
title={isHidden ? "Show in library" : "Hide from library"}>
|
|
||||||
{#if isHidden}
|
|
||||||
<EyeSlash size={13} weight="light" />
|
|
||||||
{:else}
|
|
||||||
<Eye size={13} weight="light" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="s-btn-icon" class:active={inUpdate} class:inactive={!inUpdate}
|
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInUpdate")}
|
|
||||||
title={inUpdate ? "Included in updates — click to exclude" : "Excluded from updates — click to include"}>
|
|
||||||
{#if inUpdate}
|
|
||||||
<ArrowsClockwise size={13} weight="bold" />
|
|
||||||
{:else}
|
|
||||||
<ArrowsCounterClockwise size={13} weight="light" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="s-btn-icon" class:active={inDl} class:inactive={!inDl}
|
|
||||||
onclick={() => toggleCategoryFlag(cat.id, "includeInDownload")}
|
|
||||||
title={inDl ? "Included in auto-downloads — click to exclude" : "Excluded from auto-downloads — click to include"}>
|
|
||||||
<DownloadSimple size={13} weight={inDl ? "bold" : "light"} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,8 +324,16 @@
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.s-folder-identity:active {
|
.s-folder-row-static {
|
||||||
cursor: grabbing;
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-icon-static {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
width: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.s-folder-icon {
|
.s-folder-icon {
|
||||||
@@ -326,6 +371,15 @@
|
|||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-folder-name-static {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-name-static:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.s-folder-actions {
|
.s-folder-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -334,6 +388,24 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-folder-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-folder-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.s-btn-icon.active {
|
.s-btn-icon.active {
|
||||||
color: var(--accent, #6c8ef5);
|
color: var(--accent, #6c8ef5);
|
||||||
}
|
}
|
||||||
@@ -351,4 +423,14 @@
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.s-btn-icon-lock {
|
||||||
|
opacity: 0.25;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn-icon-lock:hover {
|
||||||
|
opacity: 0.25;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -43,6 +43,7 @@ function mergeSettings(saved: any): Settings {
|
|||||||
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||||
customThemes: saved?.settings?.customThemes ?? [],
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
|
nsfwFilteredTags: saved?.settings?.nsfwFilteredTags ?? DEFAULT_SETTINGS.nsfwFilteredTags,
|
||||||
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
nsfwAllowedSourceIds: saved?.settings?.nsfwAllowedSourceIds ?? [],
|
||||||
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
nsfwBlockedSourceIds: saved?.settings?.nsfwBlockedSourceIds ?? [],
|
||||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
@@ -53,6 +54,8 @@ function mergeSettings(saved: any): Settings {
|
|||||||
readerPresets: saved?.settings?.readerPresets ?? [],
|
readerPresets: saved?.settings?.readerPresets ?? [],
|
||||||
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
mangaReaderSettings: saved?.settings?.mangaReaderSettings ?? {},
|
||||||
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
|
categoryFrecency: saved?.settings?.categoryFrecency ?? {},
|
||||||
|
hiddenLibraryTabs: saved?.settings?.hiddenLibraryTabs ?? [],
|
||||||
|
libraryPinnedTabOrder: saved?.settings?.libraryPinnedTabOrder ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface Settings {
|
|||||||
autoLinkOnOpen: boolean;
|
autoLinkOnOpen: boolean;
|
||||||
downloadToastsEnabled: boolean;
|
downloadToastsEnabled: boolean;
|
||||||
downloadAutoRetry: boolean;
|
downloadAutoRetry: boolean;
|
||||||
|
hiddenLibraryTabs: string[];
|
||||||
|
libraryPinnedTabOrder: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -167,4 +169,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoLinkOnOpen: false,
|
autoLinkOnOpen: false,
|
||||||
downloadToastsEnabled: true,
|
downloadToastsEnabled: true,
|
||||||
downloadAutoRetry: false,
|
downloadAutoRetry: false,
|
||||||
|
hiddenLibraryTabs: [],
|
||||||
|
libraryPinnedTabOrder: [],
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user