Feat: Re-Arrangement of Folders (#86)

This commit is contained in:
Youwes09
2026-05-19 20:36:44 -05:00
parent 1af21efebd
commit 3dad4bc729
3 changed files with 195 additions and 188 deletions
+32 -22
View File
@@ -71,8 +71,8 @@
let activeDragKind: "tab" | null = $state(null); let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1); let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null); let dragTabId: string | null = $state(null);
let dragOverTabId: number | null = $state(null); let dragOverTabId: string | null = $state(null);
const DT_TAB = "application/x-moku-tab"; const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true); const anims = $derived(store.settings.qolAnimations ?? true);
@@ -95,7 +95,8 @@
const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id)); const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const pinned = store.settings.libraryPinnedTabOrder ?? []; const pinned = store.settings.libraryPinnedTabOrder ?? [];
const known = new Set([...BUILTIN_TABS, ...catIds]); const known = new Set([...BUILTIN_TABS, ...catIds]);
const ordered = [...pinned.filter(id => known.has(id))]; const eligible = pinned.filter(id => known.has(id));
const ordered = [...eligible];
const inOrder = new Set(ordered); const inOrder = new Set(ordered);
for (const id of [...BUILTIN_TABS, ...catIds]) { for (const id of [...BUILTIN_TABS, ...catIds]) {
if (!inOrder.has(id)) ordered.push(id); if (!inOrder.has(id)) ordered.push(id);
@@ -517,45 +518,54 @@
}); });
} }
function onTabDragStart(e: DragEvent, cat: Category) { function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = "tab"; dragTabId = cat.id; activeDragKind = "tab"; dragTabId = id;
e.dataTransfer!.effectAllowed = "move"; e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData(DT_TAB, String(cat.id)); e.dataTransfer!.setData(DT_TAB, id);
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`); e.dataTransfer!.setData("text/plain", `tab:${id}`);
} }
function onTabDragOver(e: DragEvent, cat: Category, idx: number) { function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return; if (activeDragKind !== "tab" || dragTabId === null || dragTabId === id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move"; e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id; dragOverTabId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1; dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1;
} }
function onTabDragLeave() { dragOverTabId = null; } function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) { async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null; e.preventDefault(); dragOverTabId = null;
const insertAt = dragInsertIdx; const insertAt = dragInsertIdx;
dragInsertIdx = -1; dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; } if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropId) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null; const dragStrId = dragTabId; dragTabId = null; activeDragKind = null;
const dragStrId = String(dragId);
const tabs = [...visibleTabIds]; const tabs = [...allTabIds];
const fromIdx = tabs.indexOf(dragStrId); const fromIdx = tabs.indexOf(dragStrId);
if (fromIdx < 0) return; const dropIdx = tabs.indexOf(dropId);
if (fromIdx < 0 || dropIdx < 0) return;
const visibleDrop = visibleTabIds[insertAt] ?? null;
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length;
tabs.splice(fromIdx, 1); tabs.splice(fromIdx, 1);
const dest = Math.max(0, Math.min(insertAt > fromIdx ? insertAt - 1 : insertAt, tabs.length)); const adjustedDest = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length));
tabs.splice(dest, 0, dragStrId); tabs.splice(adjustedDest, 0, dragStrId);
updateSettings({ libraryPinnedTabOrder: tabs }); updateSettings({ libraryPinnedTabOrder: tabs });
const catIds = tabs.filter(id => id !== "library" && id !== "downloaded"); const catIds = tabs.filter(id => id !== "library" && id !== "downloaded");
const zeroCat = store.categories.filter(c => c.id === 0); const zeroCat = store.categories.filter(c => c.id === 0);
const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; }); const reordered = catIds.map((id, i) => { const c = store.categories.find(x => String(x.id) === id)!; return { ...c, order: i + 1 }; });
setCategories([...zeroCat, ...reordered]); setCategories([...zeroCat, ...reordered]);
const serverPos = catIds.indexOf(dragStrId) + 1; const dragIsBuiltin = dragStrId === "library" || dragStrId === "downloaded";
try { if (!dragIsBuiltin) {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: serverPos }); const serverPos = catIds.indexOf(dragStrId) + 1;
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); } try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: Number(dragStrId), position: serverPos });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
}
} }
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; } function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
@@ -24,8 +24,8 @@
search: string; search: string;
activeDragKind: "tab" | null; activeDragKind: "tab" | null;
dragInsertIdx: number; dragInsertIdx: number;
dragTabId: number | null; dragTabId: string | null;
dragOverTabId: number | null; dragOverTabId: string | null;
sortPanelOpen: boolean; sortPanelOpen: boolean;
filterPanelOpen: boolean; filterPanelOpen: boolean;
tabsEl: HTMLDivElement; tabsEl: HTMLDivElement;
@@ -39,10 +39,10 @@
onSortPanelToggle: () => void; onSortPanelToggle: () => void;
onFilterPanelToggle: () => void; onFilterPanelToggle: () => void;
onOpenDownloadsFolder: () => void; onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void; onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void; onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
onTabDragLeave: () => void; onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void; onTabDrop: (e: DragEvent, id: string) => void;
onTabDragEnd: () => void; onTabDragEnd: () => void;
} }
@@ -100,20 +100,23 @@
{#each visibleTabIds as id, idx} {#each visibleTabIds as id, idx}
{@const cat = visibleCategories.find(c => String(c.id) === id)} {@const cat = visibleCategories.find(c => String(c.id) === id)}
{#if id === "library" || id === "downloaded" || cat} {#if id === "library" || id === "downloaded" || cat}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = cat && id === String(completedCatId)}
{@const isDraggable = true}
{#if activeDragKind === "tab" && dragInsertIdx === idx} {#if activeDragKind === "tab" && dragInsertIdx === idx}
<div class="tab-insert-bar" aria-hidden="true"></div> <div class="tab-insert-bar" aria-hidden="true"></div>
{/if} {/if}
<button <button
class="tab" class="tab"
class:active={tab === id} class:active={tab === id}
class:tab-dragging={cat && dragTabId === cat.id} class:tab-dragging={isDraggable && dragTabId === id}
draggable={!!cat && id !== String(completedCatId)} draggable={isDraggable}
onclick={() => onTabChange(id)} onclick={() => onTabChange(id)}
ondragstart={cat && id !== String(completedCatId) ? (e) => onTabDragStart(e, cat) : undefined} ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
ondragover={cat && id !== String(completedCatId) ? (e) => onTabDragOver(e, cat, idx) : undefined} ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
ondragleave={cat && id !== String(completedCatId) ? onTabDragLeave : undefined} ondragleave={isDraggable ? onTabDragLeave : undefined}
ondrop={cat && id !== String(completedCatId) ? (e) => onTabDrop(e, cat) : undefined} ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
ondragend={cat && id !== String(completedCatId) ? onTabDragEnd : undefined} ondragend={isDraggable ? onTabDragEnd : undefined}
> >
{#if id === "library"}<Books size={11} weight="bold" /> {#if id === "library"}<Books size={11} weight="bold" />
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" /> {:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
@@ -6,13 +6,15 @@
import type { Category } from "@types"; import type { Category } from "@types";
import { store, updateSettings, 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 completedCat = $derived(store.categories.find(c => c.name === "Completed" && c.id !== 0) ?? null);
const completedId = $derived(completedCat ? String(completedCat.id) : 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 sortedCatIds = $derived(store.categories.filter(c => c.id !== 0).map(c => String(c.id)));
const orderedCatIds = $derived.by(() => {
const order = store.settings.libraryPinnedTabOrder ?? []; const orderedAllIds = $derived.by(() => {
const known = new Set(sortedCatIds); const order = store.settings.libraryPinnedTabOrder ?? [];
return [...order.filter(id => known.has(id)), ...sortedCatIds.filter(id => !order.includes(id))]; const allIds = ["library", "downloaded", ...sortedCatIds];
const known = new Set(allIds);
return [...new Set([...order.filter(id => known.has(id)), ...allIds])];
}); });
let catsLoading = $state(false); let catsLoading = $state(false);
@@ -21,9 +23,9 @@
let editingId = $state<number | null>(null); let editingId = $state<number | null>(null);
let editingName = $state(""); let editingName = $state("");
let dragId = $state<number | null>(null); let dragStrId = $state<string | null>(null);
let dragOverId = $state<number | null>(null); let dragOverStrId = $state<string | null>(null);
let dropPosition = $state<"above" | "below" | null>(null); let dropPosition = $state<"above" | "below" | null>(null);
function isHidden(id: string) { function isHidden(id: string) {
return (store.settings.hiddenLibraryTabs ?? []).includes(id); return (store.settings.hiddenLibraryTabs ?? []).includes(id);
@@ -92,57 +94,69 @@
} }
} }
async function applyReorder(fromId: number, toId: number) { function applyReorder(fromStrId: string, toStrId: string) {
const zeroCat = store.categories.filter(c => c.id === 0); const catIds = store.categories.filter(c => c.id !== 0).map(c => String(c.id));
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order); const allIds = ["library", "downloaded", ...catIds];
const fromIdx = sortable.findIndex(c => c.id === fromId); const current = store.settings.libraryPinnedTabOrder ?? [];
const toIdx = sortable.findIndex(c => c.id === toId); const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])];
const fromIdx = base.indexOf(fromStrId);
const toIdx = base.indexOf(toStrId);
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return; if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return;
const reordered = [...sortable]; base.splice(fromIdx, 1);
const [moved] = reordered.splice(fromIdx, 1); base.splice(toIdx, 0, fromStrId);
reordered.splice(toIdx, 0, moved); updateSettings({ libraryPinnedTabOrder: base });
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
const catIds = reordered.map(c => String(c.id)); const fromNumId = Number(fromStrId);
updateSettings({ libraryPinnedTabOrder: ["library", "downloaded", ...catIds] }); if (!isNaN(fromNumId) && fromStrId !== "library" && fromStrId !== "downloaded") {
const zeroCat = store.categories.filter(c => c.id === 0);
try { const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromId, position: toIdx + 1 }); const sFromIdx = sortable.findIndex(c => c.id === fromNumId);
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0); const sToIdx = sortable.findIndex(c => String(c.id) === toStrId);
setCategories([ if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
...zeroCat, const reordered = [...sortable];
...updated.sort((a, b) => a.order - b.order).map(fresh => { const [moved] = reordered.splice(sFromIdx, 1);
const existing = store.categories.find(c => c.id === fresh.id); reordered.splice(sToIdx, 0, moved);
return existing ? { ...existing, ...fresh } : fresh; setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
}), gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: fromNumId, position: sToIdx + 1 })
]); .then(res => {
} catch (e: any) { const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
catsError = e?.message ?? "Failed to reorder"; setCategories([
await loadCategories(); ...zeroCat,
...updated.sort((a, b) => a.order - b.order).map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
})
.catch(async (e: any) => {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
});
}
} }
} }
function onDragStart(e: DragEvent, id: number) { function onDragStart(e: DragEvent, id: string) {
dragId = id; dragStrId = id;
if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(id)); } if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", id); }
} }
function onDragOver(e: DragEvent, id: number) { function onDragOver(e: DragEvent, id: string) {
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragId === id) return; if (dragStrId === id) return;
dragOverId = id; dragOverStrId = id;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below"; dropPosition = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
} }
function onDrop(e: DragEvent, id: number) { function onDrop(e: DragEvent, id: string) {
e.preventDefault(); e.preventDefault();
if (dragId !== null && dragId !== id) applyReorder(dragId, id); if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id);
dragId = null; dragOverId = null; dropPosition = null; dragStrId = null; dragOverStrId = null; dropPosition = null;
} }
function onDragEnd() { dragId = null; dragOverId = null; dropPosition = null; } function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null; }
function focusInput(node: HTMLElement) { node.focus(); } function focusInput(node: HTMLElement) { node.focus(); }
@@ -166,96 +180,94 @@
{#if catsLoading} {#if catsLoading}
<p class="s-empty">Loading folders…</p> <p class="s-empty">Loading folders…</p>
{:else} {:else}
<div class="s-folder-row s-folder-row-static"> <div class="s-folder-list" class:is-dragging={dragStrId !== null}>
<span class="s-folder-icon-static"><BookmarkSimple size={14} weight="light" /></span> {#each orderedAllIds as id}
<span class="s-folder-name s-folder-name-static">Saved</span> {@const isBuiltin = id === "library" || id === "downloaded"}
<span class="s-folder-badge">built-in</span> {@const isCompleted = id === completedId}
<div class="s-folder-actions"> {@const cat = isBuiltin ? null : (store.categories.find(c => String(c.id) === id) ?? null)}
<button class="s-btn-icon" class:muted={isHidden("library")} onclick={() => toggleHidden("library")} title={isHidden("library") ? "Show tab in library" : "Hide tab from library"}> {@const hidden = isHidden(id)}
{#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} {#if isBuiltin || cat}
<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 <div
class="s-folder-row" class="s-folder-row"
class:dragging={dragId === cat.id} class:dragging={dragStrId === id}
class:drop-above={dragOverId === cat.id && dragId !== cat.id && dropPosition === "above"} class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === "above"}
class:drop-below={dragOverId === cat.id && dragId !== cat.id && dropPosition === "below"} class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === "below"}
ondragover={(e) => onDragOver(e, cat.id)} draggable="true"
ondrop={(e) => onDrop(e, cat.id)} ondragstart={(e) => onDragStart(e, id)}
ondragleave={() => { if (dragOverId === cat.id) { dragOverId = null; dropPosition = null; } }} ondragover={(e) => onDragOver(e, id)}
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null; } }}
ondrop={(e) => onDrop(e, id)}
ondragend={onDragEnd}
> >
{#if editingId === cat.id} {#if isCompleted}
<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>
<span class="s-folder-icon">
<CheckSquare size={14} weight="light" />
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{cat?.name ?? "Completed"}</span>
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions"> <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"}> <button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
<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} {#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button> </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"}> <button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
{#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> </div>
{:else if isBuiltin}
<span class="s-folder-icon">
{#if id === "library"}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
<DotsSixVertical size={14} weight="bold" />
</span>
<span class="s-folder-name">{id === "library" ? "Saved" : "Downloaded"}</span>
<span class="s-folder-badge">built-in</span>
<div class="s-folder-actions">
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? "Show tab in library" : "Hide tab from library"}>
{#if hidden}<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>
{:else if cat}
{#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, 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}
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -314,28 +326,24 @@
.s-folder-row.drop-above::before { top: -1px; } .s-folder-row.drop-above::before { top: -1px; }
.s-folder-row.drop-below::after { bottom: -1px; } .s-folder-row.drop-below::after { bottom: -1px; }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-row-static {
cursor: default;
}
.s-folder-icon-static { .s-folder-icon-static {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-faint); color: var(--text-primary);
width: 14px; width: 14px;
} }
.s-folder-identity {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
flex-shrink: 0;
overflow: hidden;
cursor: grab;
}
.s-folder-icon { .s-folder-icon {
display: grid; display: grid;
flex-shrink: 0; flex-shrink: 0;
@@ -371,14 +379,6 @@
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;
@@ -400,12 +400,6 @@
margin-left: 6px; 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);
} }