[V1] Folder/Tag System

This commit is contained in:
Youwes09
2026-02-22 16:44:25 -06:00
parent 7ed7ec0ea3
commit 9e7f66e302
10 changed files with 748 additions and 91 deletions
+87 -25
View File
@@ -1,10 +1,9 @@
import { useEffect, useState, useMemo, useCallback, memo } from "react";
import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react";
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
import { useStore } from "../../store";
import type { LibraryFilter } from "../../store";
import type { Manga } from "../../lib/types";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import s from "./Library.module.css";
@@ -51,16 +50,15 @@ export default function Library() {
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
const setActiveManga = useStore((state) => state.setActiveManga);
const libraryFilter = useStore((state) => state.libraryFilter);
const setActiveManga = useStore((state) => state.setActiveManga);
const libraryFilter = useStore((state) => state.libraryFilter);
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
const settings = useStore((state) => state.settings);
const settings = useStore((state) => state.settings);
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
const folders = useStore((state) => state.settings.folders);
useEffect(() => {
// Fetch all manga (for downloaded filter on non-library entries) and
// library manga (for unreadCount/chapter progress). Merge: library wins.
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
@@ -73,27 +71,47 @@ export default function Library() {
.finally(() => setLoading(false));
}, []);
// Reset visible count when filter/search changes
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]);
// Reset filter if the active folder tab gets hidden
useEffect(() => {
const activeFolder = folders.find((f) => f.id === libraryFilter);
if (activeFolder && !activeFolder.showTab) {
setLibraryFilter("library");
}
}, [folders]);
const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded";
const filtered = useMemo(() => {
let items = allManga;
// Apply filter tab
if (libraryFilter === "library") {
items = items.filter((m) => m.inLibrary);
} else if (libraryFilter === "downloaded") {
items = items.filter((m) => (m.downloadCount ?? 0) > 0);
} else if (!isBuiltinFilter) {
// folder filter
const folder = folders.find((f) => f.id === libraryFilter);
if (folder) {
items = items.filter((m) => folder.mangaIds.includes(m.id));
}
}
// tag filter only applies to library/all/folder views
if (libraryTagFilter.length > 0) {
items = items.filter((m) =>
libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag))
);
}
// Apply search
if (search.trim()) {
const q = search.toLowerCase();
items = items.filter((m) => m.title.toLowerCase().includes(q));
}
return items;
}, [allManga, libraryFilter, search]);
}, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
@@ -108,10 +126,26 @@ export default function Library() {
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
}
async function deleteAllDownloads(manga: Manga) {
try {
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
const downloadedIds = data.chapters.nodes
.filter((c) => c.isDownloaded)
.map((c) => c.id);
if (!downloadedIds.length) return;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: downloadedIds });
setAllManga((prev) =>
prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)
);
} catch (e) {
console.error(e);
}
}
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault();
const menuW = 200;
const menuH = 96;
const menuH = 160;
const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
setCtx({ x, y, manga: m });
@@ -127,25 +161,39 @@ export default function Library() {
{
label: m.inLibrary ? "Remove from library" : "Add to library",
danger: m.inLibrary,
onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
.catch(console.error),
onClick: () => m.inLibrary
? removeFromLibrary(m)
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
.catch(console.error),
},
{
label: "Delete all downloads",
danger: true,
disabled: !(m.downloadCount && m.downloadCount > 0),
icon: <Trash size={13} weight="light" />,
onClick: () => deleteAllDownloads(m),
},
];
}
// All genres present in current library
const allTags = useMemo(() => {
const tagSet = new Set<string>();
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
return Array.from(tagSet).sort();
}, [allManga]);
const counts = useMemo(() => ({
all: allManga.length,
library: allManga.filter((m) => m.inLibrary).length,
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
}), [allManga]);
const counts = useMemo(() => {
const result: Record<string, number> = {
all: allManga.length,
library: allManga.filter((m) => m.inLibrary).length,
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
};
folders.forEach((f) => {
result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length;
});
return result;
}, [allManga, folders]);
if (error) return (
<div className={s.center}>
@@ -160,7 +208,8 @@ export default function Library() {
<div className={s.headerLeft}>
<h1 className={s.heading}>Library</h1>
<div className={s.tabs}>
{(["library", "downloaded", "all"] as LibraryFilter[]).map((f) => (
{/* Built-in tabs */}
{(["library", "downloaded", "all"] as const).map((f) => (
<button
key={f}
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
@@ -176,6 +225,18 @@ export default function Library() {
<span className={s.tabCount}>{counts[f]}</span>
</button>
))}
{/* Folder tabs — only shown if the folder has showTab enabled */}
{folders.filter((f) => f.showTab).map((folder) => (
<button
key={folder.id}
className={[s.tab, libraryFilter === folder.id ? s.tabActive : ""].join(" ").trim()}
onClick={() => setLibraryFilter(folder.id)}
>
<Folder size={11} weight="bold" />
{folder.name}
<span className={s.tabCount}>{counts[folder.id] ?? 0}</span>
</button>
))}
</div>
</div>
<div className={s.searchWrap}>
@@ -189,7 +250,6 @@ export default function Library() {
</div>
</div>
{/* Tag filter panel */}
{allTags.length > 0 && (
<div className={s.tagPanel}>
@@ -233,6 +293,8 @@ export default function Library() {
? "No manga saved to library. Browse sources to add some."
: libraryFilter === "downloaded"
? "No downloaded manga."
: !isBuiltinFilter
? "No manga in this folder yet. Right-click manga to assign them."
: "No manga found."}
</div>
) : (
@@ -693,4 +693,184 @@
border: 1px solid var(--border-dim);
display: flex; align-items: center; justify-content: center;
padding: var(--sp-2);
}
/* ── Folder picker (icon button in list header) ──────────────────────── */
.folderPickerWrap {
position: relative;
}
/* Matches dlToggleBtn / viewToggleBtn style */
.folderPickerBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
color: var(--text-muted);
background: none;
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.folderPickerBtn:hover {
color: var(--text-secondary);
border-color: var(--border-strong);
background: var(--bg-raised);
}
/* Active state when manga is assigned to at least one folder */
.folderPickerBtnActive {
color: var(--accent-fg);
border-color: var(--accent-dim);
background: var(--accent-muted);
}
.folderPickerBtnActive:hover {
background: var(--accent-muted);
border-color: var(--accent);
color: var(--accent-fg);
}
.folderPickerMenu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 180px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
padding: var(--sp-1);
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both;
transform-origin: top right;
}
.folderPickerEmpty {
padding: var(--sp-2) var(--sp-3);
font-size: var(--text-xs);
color: var(--text-faint);
}
.folderPickerItem {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 6px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.folderPickerItem:hover { background: var(--bg-overlay); }
.folderPickerItemActive { color: var(--accent-fg); }
.folderPickerItemCheck {
width: 12px;
font-size: var(--text-xs);
color: var(--accent-fg);
flex-shrink: 0;
}
.folderPickerDivider {
height: 1px;
background: var(--border-dim);
margin: var(--sp-1) var(--sp-2);
}
.folderPickerCreate {
display: flex;
align-items: center;
gap: var(--sp-1);
padding: 4px var(--sp-2);
}
.folderPickerInput {
flex: 1;
background: var(--bg-overlay);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: var(--text-xs);
color: var(--text-secondary);
outline: none;
min-width: 0;
}
.folderPickerInput:focus { border-color: var(--border-focus); }
.folderPickerConfirm {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 4px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim);
background: var(--accent-muted);
color: var(--accent-fg);
cursor: pointer;
flex-shrink: 0;
}
.folderPickerConfirm:disabled { opacity: 0.4; cursor: default; }
.folderPickerCancel {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.folderPickerCancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
.folderPickerNewBtn {
width: 100%;
padding: 6px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: color var(--t-fast), background var(--t-fast);
}
.folderPickerNewBtn:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Delete all downloads button (in details section) ─────────────────── */
.deleteAllBtn {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
margin-top: var(--sp-2);
padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: var(--color-error);
background: none;
border: 1px solid var(--color-error);
cursor: pointer;
text-align: left;
transition: background var(--t-base);
}
.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.deleteAllBtn:disabled { opacity: 0.4; cursor: default; }
/* ── Danger item in dl dropdown ─────────────────────────────────────── */
.dlItemDanger {
color: var(--color-error) !important;
}
.dlItemDanger:hover:not(:disabled) {
background: var(--color-error-bg) !important;
}
+143 -4
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import {
ArrowLeft, BookmarkSimple, Download, CheckCircle,
ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour,
List, SquaresFour, FolderSimplePlus, X, Trash,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import {
@@ -33,6 +33,107 @@ interface CtxState {
const CHAPTERS_PER_PAGE = 25;
// ── Folder picker (icon button for list header) ───────────────────────────────
function FolderPicker({ mangaId }: { mangaId: number }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useStore((st) => st.settings.folders);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder);
const addFolder = useStore((st) => st.addFolder);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const assigned = folders.filter((f) => f.mangaIds.includes(mangaId));
const hasAssigned = assigned.length > 0;
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setNewName("");
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
function handleCreate() {
const name = newName.trim();
if (!name) return;
const id = addFolder(name);
assignMangaToFolder(id, mangaId);
setNewName("");
setCreating(false);
}
return (
<div className={s.folderPickerWrap} ref={ref}>
<button
className={[s.folderPickerBtn, hasAssigned ? s.folderPickerBtnActive : ""].join(" ")}
onClick={() => setOpen((p) => !p)}
title={hasAssigned ? `Folders: ${assigned.map((f) => f.name).join(", ")}` : "Add to folder"}
>
<FolderSimplePlus size={14} weight={hasAssigned ? "fill" : "light"} />
</button>
{open && (
<div className={s.folderPickerMenu}>
{folders.length === 0 && !creating && (
<p className={s.folderPickerEmpty}>No folders yet</p>
)}
{folders.map((folder) => {
const isIn = folder.mangaIds.includes(mangaId);
return (
<button
key={folder.id}
className={[s.folderPickerItem, isIn ? s.folderPickerItemActive : ""].join(" ")}
onClick={() =>
isIn
? removeMangaFromFolder(folder.id, mangaId)
: assignMangaToFolder(folder.id, mangaId)
}
>
<span className={s.folderPickerItemCheck}>{isIn ? "✓" : ""}</span>
{folder.name}
</button>
);
})}
<div className={s.folderPickerDivider} />
{creating ? (
<div className={s.folderPickerCreate}>
<input
autoFocus
className={s.folderPickerInput}
placeholder="Folder name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
if (e.key === "Escape") { setCreating(false); setNewName(""); }
}}
/>
<button className={s.folderPickerConfirm} onClick={handleCreate} disabled={!newName.trim()}>
Add
</button>
<button className={s.folderPickerCancel} onClick={() => { setCreating(false); setNewName(""); }}>
<X size={12} weight="light" />
</button>
</div>
) : (
<button className={s.folderPickerNewBtn} onClick={() => setCreating(true)}>
+ New folder
</button>
)}
</div>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function SeriesDetail() {
const activeManga = useStore((state) => state.activeManga);
const setActiveManga = useStore((state) => state.setActiveManga);
@@ -54,6 +155,7 @@ export default function SeriesDetail() {
const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [deletingAll, setDeletingAll] = useState(false);
const sortDir = settings.chapterSortDir;
@@ -104,15 +206,14 @@ export default function SeriesDetail() {
const readCount = chapters.filter((c) => c.isRead).length;
const totalCount = chapters.length;
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const continueChapter = useMemo(() => {
if (!chapters.length) return null;
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some((c) => c.isRead);
// In-progress: started but not finished
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const };
// If any chapter is read, user is continuing — find next unread
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const };
return { chapter: asc[0], type: "reread" as const };
@@ -153,6 +254,15 @@ export default function SeriesDetail() {
setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c));
}
async function deleteAllDownloads() {
const ids = chapters.filter((c) => c.isDownloaded).map((c) => c.id);
if (!ids.length) return;
setDeletingAll(true);
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false })));
setDeletingAll(false);
}
async function enqueueMultiple(chapterIds: number[]) {
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
if (activeManga) loadChapters(activeManga.id);
@@ -279,6 +389,8 @@ export default function SeriesDetail() {
)}
</div>
{/* Folder picker moved to chapter list header */}
{continueChapter && (
<button
className={s.readBtn}
@@ -327,6 +439,18 @@ export default function SeriesDetail() {
<ArrowsClockwise size={12} weight="light" />
Switch source
</button>
{/* Delete all downloads */}
{downloadedCount > 0 && (
<button
className={s.deleteAllBtn}
onClick={deleteAllDownloads}
disabled={deletingAll}
>
<Trash size={12} weight="light" />
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
</button>
)}
</div>
)}
</div>
@@ -365,6 +489,9 @@ export default function SeriesDetail() {
</div>
<div className={s.listHeaderRight}>
{/* Folder picker */}
{activeManga && <FolderPicker mangaId={activeManga.id} />}
{/* Jump to chapter */}
{chapters.length > 1 && (
<div className={s.jumpWrap}>
@@ -440,6 +567,18 @@ export default function SeriesDetail() {
<span>Download all</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
</button>
{downloadedCount > 0 && (
<>
<div style={{ height: 1, background: "var(--border-dim)", margin: "var(--sp-1) var(--sp-2)" }} />
<button className={[s.dlItem, s.dlItemDanger].join(" ")}
onClick={() => { deleteAllDownloads(); setDlOpen(false); }}
disabled={deletingAll}
>
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
<span className={s.dlItemSub}>{downloadedCount} downloaded</span>
</button>
</>
)}
</div>
)}
</div>