mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Re-Arrangement of Folders (#86)
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user