[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
+1 -1
View File
@@ -181,7 +181,7 @@ modules:
path: . path: .
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 386b393cd29f84064a3abef926237cb8a028da49c930a24ead7ad8a67d671a9c sha256: 58b475fccdd41807dfd5e55e236925b9d88ed1df49367fb93f7e463d7ae204d2
- packaging/cargo-sources.json - packaging/cargo-sources.json
- type: inline - type: inline
dest: src-tauri/.cargo dest: src-tauri/.cargo
Binary file not shown.
+19 -42
View File
@@ -15,29 +15,32 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: var(--sp-3); margin-bottom: var(--sp-3);
overflow: visible; overflow: visible;
background: none;
border: none;
cursor: pointer;
border-radius: var(--radius-lg);
transition: opacity var(--t-base), transform var(--t-base);
padding: 0;
} }
.logo:hover { opacity: 0.8; transform: scale(0.96); }
.logo:active { transform: scale(0.92); }
.logoIcon { .logoIcon {
width: 80px; width: 80px;
height: 80px; height: 80px;
background-color: var(--accent); background-color: var(--accent);
mask-image: url("../../assets/moku-icon.svg"); mask-image: url("../../assets/moku-icon.svg");
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
mask-size: contain; mask-size: contain;
-webkit-mask-image: url("../../assets/moku-icon.svg"); -webkit-mask-image: url("../../assets/moku-icon.svg");
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
-webkit-mask-position: center; -webkit-mask-position: center;
-webkit-mask-size: contain; -webkit-mask-size: contain;
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35)); filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
pointer-events: none;
} }
.nav { .nav {
@@ -51,54 +54,28 @@
} }
.tab { .tab {
width: 36px; width: 36px; height: 36px;
height: 36px; display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base); transition: color var(--t-base), background var(--t-base);
} }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tab:hover { .tabActive { color: var(--accent-fg); background: var(--accent-muted); }
color: var(--text-muted); .tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
background: var(--bg-raised);
}
.tabActive {
color: var(--accent-fg);
background: var(--accent-muted);
}
.tabActive:hover {
color: var(--accent-fg);
background: var(--accent-muted);
}
.bottom { .bottom {
display: flex; display: flex; flex-direction: column; align-items: center;
flex-direction: column; width: 100%; padding: var(--sp-3) var(--sp-2) 0;
align-items: center;
width: 100%;
padding: var(--sp-3) var(--sp-2) 0;
border-top: 1px solid var(--border-dim); border-top: 1px solid var(--border-dim);
margin-top: var(--sp-3); margin-top: var(--sp-3);
} }
.settingsBtn { .settingsBtn {
width: 36px; width: 36px; height: 36px;
height: 36px; display: flex; align-items: center; justify-content: center;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--text-faint); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base), transform var(--t-slow); transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
} }
.settingsBtn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
.settingsBtn:hover {
color: var(--text-muted);
background: var(--bg-raised);
transform: rotate(30deg);
}
+13 -3
View File
@@ -18,6 +18,8 @@ export default function Sidebar() {
const navPage = useStore((state) => state.navPage); const navPage = useStore((state) => state.navPage);
const setNavPage = useStore((state) => state.setNavPage); const setNavPage = useStore((state) => state.setNavPage);
const setActiveSource = useStore((state) => state.setActiveSource); const setActiveSource = useStore((state) => state.setActiveSource);
const setActiveManga = useStore((state) => state.setActiveManga);
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
const openSettings = useStore((state) => state.openSettings); const openSettings = useStore((state) => state.openSettings);
function navigate(id: NavPage) { function navigate(id: NavPage) {
@@ -25,11 +27,19 @@ export default function Sidebar() {
if (id !== "sources") setActiveSource(null); if (id !== "sources") setActiveSource(null);
} }
function goHome() {
setNavPage("library");
setActiveSource(null);
setActiveManga(null);
setLibraryFilter("library");
}
return ( return (
<aside className={s.root}> <aside className={s.root}>
<div className={s.logo}> {/* Logo click → back to library root */}
<div className={s.logoIcon} aria-label="Moku Logo" /> <button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
</div> <div className={s.logoIcon} />
</button>
<nav className={s.nav}> <nav className={s.nav}>
{TABS.map((tab) => ( {TABS.map((tab) => (
<button key={tab.id} title={tab.label} <button key={tab.id} title={tab.label}
+79 -17
View File
@@ -1,10 +1,9 @@
import { useEffect, useState, useMemo, useCallback, memo } from "react"; 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 { 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 { useStore } from "../../store";
import type { LibraryFilter } from "../../store"; import type { Manga, Chapter } from "../../lib/types";
import type { Manga } from "../../lib/types";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import s from "./Library.module.css"; import s from "./Library.module.css";
@@ -57,10 +56,9 @@ export default function Library() {
const settings = useStore((state) => state.settings); const settings = useStore((state) => state.settings);
const libraryTagFilter = useStore((state) => state.libraryTagFilter); const libraryTagFilter = useStore((state) => state.libraryTagFilter);
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter); const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
const folders = useStore((state) => state.settings.folders);
useEffect(() => { useEffect(() => {
// Fetch all manga (for downloaded filter on non-library entries) and
// library manga (for unreadCount/chapter progress). Merge: library wins.
Promise.all([ Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY), gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
@@ -73,27 +71,47 @@ export default function Library() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
// Reset visible count when filter/search changes
useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]); 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(() => { const filtered = useMemo(() => {
let items = allManga; let items = allManga;
// Apply filter tab
if (libraryFilter === "library") { if (libraryFilter === "library") {
items = items.filter((m) => m.inLibrary); items = items.filter((m) => m.inLibrary);
} else if (libraryFilter === "downloaded") { } else if (libraryFilter === "downloaded") {
items = items.filter((m) => (m.downloadCount ?? 0) > 0); 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()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
items = items.filter((m) => m.title.toLowerCase().includes(q)); items = items.filter((m) => m.title.toLowerCase().includes(q));
} }
return items; return items;
}, [allManga, libraryFilter, search]); }, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]);
const visible = filtered.slice(0, visibleCount); const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length; 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)); 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) { function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.preventDefault();
const menuW = 200; const menuW = 200;
const menuH = 96; const menuH = 160;
const x = Math.min(e.clientX, window.innerWidth - menuW - 8); const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
const y = Math.min(e.clientY, window.innerHeight - menuH - 8); const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
setCtx({ x, y, manga: m }); setCtx({ x, y, manga: m });
@@ -127,25 +161,39 @@ export default function Library() {
{ {
label: m.inLibrary ? "Remove from library" : "Add to library", label: m.inLibrary ? "Remove from library" : "Add to library",
danger: m.inLibrary, danger: m.inLibrary,
onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) 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))) .then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
.catch(console.error), .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 allTags = useMemo(() => {
const tagSet = new Set<string>(); const tagSet = new Set<string>();
allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g))); allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g)));
return Array.from(tagSet).sort(); return Array.from(tagSet).sort();
}, [allManga]); }, [allManga]);
const counts = useMemo(() => ({ const counts = useMemo(() => {
const result: Record<string, number> = {
all: allManga.length, all: allManga.length,
library: allManga.filter((m) => m.inLibrary).length, library: allManga.filter((m) => m.inLibrary).length,
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length, downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
}), [allManga]); };
folders.forEach((f) => {
result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length;
});
return result;
}, [allManga, folders]);
if (error) return ( if (error) return (
<div className={s.center}> <div className={s.center}>
@@ -160,7 +208,8 @@ export default function Library() {
<div className={s.headerLeft}> <div className={s.headerLeft}>
<h1 className={s.heading}>Library</h1> <h1 className={s.heading}>Library</h1>
<div className={s.tabs}> <div className={s.tabs}>
{(["library", "downloaded", "all"] as LibraryFilter[]).map((f) => ( {/* Built-in tabs */}
{(["library", "downloaded", "all"] as const).map((f) => (
<button <button
key={f} key={f}
className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()} className={[s.tab, libraryFilter === f ? s.tabActive : ""].join(" ").trim()}
@@ -176,6 +225,18 @@ export default function Library() {
<span className={s.tabCount}>{counts[f]}</span> <span className={s.tabCount}>{counts[f]}</span>
</button> </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> </div>
<div className={s.searchWrap}> <div className={s.searchWrap}>
@@ -189,7 +250,6 @@ export default function Library() {
</div> </div>
</div> </div>
{/* Tag filter panel */} {/* Tag filter panel */}
{allTags.length > 0 && ( {allTags.length > 0 && (
<div className={s.tagPanel}> <div className={s.tagPanel}>
@@ -233,6 +293,8 @@ export default function Library() {
? "No manga saved to library. Browse sources to add some." ? "No manga saved to library. Browse sources to add some."
: libraryFilter === "downloaded" : libraryFilter === "downloaded"
? "No downloaded manga." ? "No downloaded manga."
: !isBuiltinFilter
? "No manga in this folder yet. Right-click manga to assign them."
: "No manga found."} : "No manga found."}
</div> </div>
) : ( ) : (
@@ -694,3 +694,183 @@
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
padding: var(--sp-2); 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 { import {
ArrowLeft, BookmarkSimple, Download, CheckCircle, ArrowLeft, BookmarkSimple, Download, CheckCircle,
ArrowSquareOut, BookOpen, CircleNotch, Play, ArrowSquareOut, BookOpen, CircleNotch, Play,
SortAscending, SortDescending, CaretDown, ArrowsClockwise, SortAscending, SortDescending, CaretDown, ArrowsClockwise,
List, SquaresFour, List, SquaresFour, FolderSimplePlus, X, Trash,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { import {
@@ -33,6 +33,107 @@ interface CtxState {
const CHAPTERS_PER_PAGE = 25; 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() { export default function SeriesDetail() {
const activeManga = useStore((state) => state.activeManga); const activeManga = useStore((state) => state.activeManga);
const setActiveManga = useStore((state) => state.setActiveManga); const setActiveManga = useStore((state) => state.setActiveManga);
@@ -54,6 +155,7 @@ export default function SeriesDetail() {
const [jumpOpen, setJumpOpen] = useState(false); const [jumpOpen, setJumpOpen] = useState(false);
const [jumpInput, setJumpInput] = useState(""); const [jumpInput, setJumpInput] = useState("");
const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [deletingAll, setDeletingAll] = useState(false);
const sortDir = settings.chapterSortDir; const sortDir = settings.chapterSortDir;
@@ -104,15 +206,14 @@ export default function SeriesDetail() {
const readCount = chapters.filter((c) => c.isRead).length; const readCount = chapters.filter((c) => c.isRead).length;
const totalCount = chapters.length; const totalCount = chapters.length;
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0; const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
const downloadedCount = chapters.filter((c) => c.isDownloaded).length;
const continueChapter = useMemo(() => { const continueChapter = useMemo(() => {
if (!chapters.length) return null; if (!chapters.length) return null;
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some((c) => c.isRead); const anyRead = asc.some((c) => c.isRead);
// In-progress: started but not finished
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const }; 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); const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const }; if (firstUnread) return { chapter: firstUnread, type: anyRead ? "continue" : "start" as const };
return { chapter: asc[0], type: "reread" 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)); 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[]) { async function enqueueMultiple(chapterIds: number[]) {
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
if (activeManga) loadChapters(activeManga.id); if (activeManga) loadChapters(activeManga.id);
@@ -279,6 +389,8 @@ export default function SeriesDetail() {
)} )}
</div> </div>
{/* Folder picker moved to chapter list header */}
{continueChapter && ( {continueChapter && (
<button <button
className={s.readBtn} className={s.readBtn}
@@ -327,6 +439,18 @@ export default function SeriesDetail() {
<ArrowsClockwise size={12} weight="light" /> <ArrowsClockwise size={12} weight="light" />
Switch source Switch source
</button> </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>
)} )}
</div> </div>
@@ -365,6 +489,9 @@ export default function SeriesDetail() {
</div> </div>
<div className={s.listHeaderRight}> <div className={s.listHeaderRight}>
{/* Folder picker */}
{activeManga && <FolderPicker mangaId={activeManga.id} />}
{/* Jump to chapter */} {/* Jump to chapter */}
{chapters.length > 1 && ( {chapters.length > 1 && (
<div className={s.jumpWrap}> <div className={s.jumpWrap}>
@@ -440,6 +567,18 @@ export default function SeriesDetail() {
<span>Download all</span> <span>Download all</span>
<span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span> <span className={s.dlItemSub}>{sortedChapters.filter((c) => !c.isDownloaded).length} not downloaded</span>
</button> </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>
)} )}
</div> </div>
@@ -372,3 +372,90 @@
} }
.dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); } .dangerBtn:hover:not(:disabled) { background: var(--color-error-bg); }
.dangerBtn:disabled { opacity: 0.3; cursor: default; } .dangerBtn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder management (Settings FoldersTab) ────────────────────────── */
.folderCreateRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-3) var(--sp-3);
}
.folderCreateBtn {
display: flex;
align-items: center;
gap: var(--sp-1);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 5px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base);
}
.folderCreateBtn:hover:not(:disabled) {
color: var(--accent-fg);
border-color: var(--accent);
}
.folderCreateBtn:disabled { opacity: 0.3; cursor: default; }
.folderList {
display: flex;
flex-direction: column;
gap: 1px;
}
.folderRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 9px var(--sp-3);
border-radius: var(--radius-md);
transition: background var(--t-fast);
}
.folderRow:hover { background: var(--bg-raised); }
.folderRowName {
flex: 1;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.folderRowCount {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-right: var(--sp-1);
}
.folderTabToggle {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 3px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.folderTabToggle:hover {
color: var(--text-muted);
border-color: var(--border-strong);
}
.folderTabToggleOn {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.folderTabToggleOn:hover {
background: var(--accent-muted);
color: var(--accent-fg);
}
+126 -3
View File
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives } from "@phosphor-icons/react"; import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash } from "@phosphor-icons/react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { gql } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_DOWNLOADS_PATH } from "../../lib/queries"; import { GET_DOWNLOADS_PATH } from "../../lib/queries";
import { useStore } from "../../store"; import { useStore } from "../../store";
import type { Folder } from "../../store";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds"; import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
import type { Settings, FitMode } from "../../store"; import type { Settings, FitMode } from "../../store";
import s from "./Settings.module.css"; import s from "./Settings.module.css";
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "about"; type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about";
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> }, { id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
@@ -17,6 +18,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> }, { id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> }, { id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> }, { id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
{ id: "folders", label: "Folders", icon: <FolderSimple size={14} weight="light" /> },
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> }, { id: "about", label: "About", icon: <Info size={14} weight="light" /> },
]; ];
@@ -566,6 +568,127 @@ function StorageTab({ settings, update }: { settings: Settings; update: (p: Part
); );
} }
// ── Folders tab ───────────────────────────────────────────────────────────────
function FoldersTab() {
const folders = useStore((s) => s.settings.folders);
const addFolder = useStore((s) => s.addFolder);
const removeFolder = useStore((s) => s.removeFolder);
const renameFolder = useStore((s) => s.renameFolder);
const toggleFolderTab = useStore((s) => s.toggleFolderTab);
const [newName, setNewName] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
function handleCreate() {
const name = newName.trim();
if (!name) return;
addFolder(name);
setNewName("");
}
function startEdit(folder: Folder) {
setEditingId(folder.id);
setEditingName(folder.name);
}
function commitEdit() {
if (editingId && editingName.trim()) {
renameFolder(editingId, editingName.trim());
}
setEditingId(null);
setEditingName("");
}
return (
<div className={s.panel}>
<div className={s.section}>
<p className={s.sectionTitle}>Manage Folders</p>
<p className={s.toggleDesc} style={{ padding: "0 var(--sp-3) var(--sp-3)", display: "block" }}>
Assign manga to folders from the series detail page. Toggle the tab icon to show a folder as a filter tab in the library.
</p>
{/* Create new folder */}
<div className={s.folderCreateRow}>
<input
className={s.textInput}
placeholder="New folder name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
style={{ flex: 1, width: "auto" }}
/>
<button
className={s.folderCreateBtn}
onClick={handleCreate}
disabled={!newName.trim()}
>
<Plus size={13} weight="bold" />
Create
</button>
</div>
{/* Folder list */}
{folders.length === 0 ? (
<p className={s.storageLoading}>No folders yet. Create one above.</p>
) : (
<div className={s.folderList}>
{folders.map((folder) => (
<div key={folder.id} className={s.folderRow}>
{editingId === folder.id ? (
<>
<input
autoFocus
className={s.textInput}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") { setEditingId(null); }
}}
onBlur={commitEdit}
style={{ flex: 1, width: "auto" }}
/>
<button className={s.kbReset} onClick={commitEdit} title="Save"></button>
</>
) : (
<>
<FolderSimple size={14} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
<span className={s.folderRowName}>{folder.name}</span>
<span className={s.folderRowCount}>{folder.mangaIds.length} manga</span>
{/* Show as tab toggle */}
<button
className={[s.folderTabToggle, folder.showTab ? s.folderTabToggleOn : ""].join(" ")}
onClick={() => toggleFolderTab(folder.id)}
title={folder.showTab ? "Shown as library tab — click to hide" : "Click to show as library tab"}
>
{folder.showTab ? "Tab on" : "Tab off"}
</button>
<button
className={s.kbReset}
onClick={() => startEdit(folder)}
title="Rename"
>
<Pencil size={12} weight="light" />
</button>
<button
className={[s.kbReset, s.folderDeleteBtn].join(" ")}
onClick={() => removeFolder(folder.id)}
title="Delete folder"
>
<Trash size={12} weight="light" />
</button>
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
function AboutTab() { function AboutTab() {
return ( return (
@@ -594,7 +717,6 @@ export default function SettingsModal() {
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(null); const contentBodyRef = useRef<HTMLDivElement>(null);
// Scroll to top on every tab switch
useEffect(() => { useEffect(() => {
contentBodyRef.current?.scrollTo({ top: 0 }); contentBodyRef.current?.scrollTo({ top: 0 });
}, [tab]); }, [tab]);
@@ -638,6 +760,7 @@ export default function SettingsModal() {
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />} {tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />} {tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />} {tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
{tab === "folders" && <FoldersTab />}
{tab === "about" && <AboutTab />} {tab === "about" && <AboutTab />}
</div> </div>
</div> </div>
+81 -2
View File
@@ -5,7 +5,7 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
export type PageStyle = "single" | "double" | "longstrip"; export type PageStyle = "single" | "double" | "longstrip";
export type FitMode = "width" | "height" | "screen" | "original"; export type FitMode = "width" | "height" | "screen" | "original";
export type LibraryFilter = "all" | "library" | "downloaded"; export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id
export type NavPage = "library" | "sources" | "downloads" | "extensions" | "history" | "search"; export type NavPage = "library" | "sources" | "downloads" | "extensions" | "history" | "search";
export type ReadingDirection = "ltr" | "rtl"; export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = "desc" | "asc";
@@ -26,6 +26,13 @@ export interface ActiveDownload {
progress: number; progress: number;
} }
export interface Folder {
id: string;
name: string;
mangaIds: number[];
showTab: boolean;
}
export interface Settings { export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
@@ -51,6 +58,7 @@ export interface Settings {
preferredExtensionLang: string; preferredExtensionLang: string;
keybinds: Keybinds; keybinds: Keybinds;
storageLimitGb: number | null; storageLimitGb: number | null;
folders: Folder[];
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -78,6 +86,7 @@ export const DEFAULT_SETTINGS: Settings = {
preferredExtensionLang: "en", preferredExtensionLang: "en",
keybinds: DEFAULT_KEYBINDS, keybinds: DEFAULT_KEYBINDS,
storageLimitGb: null, storageLimitGb: null,
folders: [],
}; };
interface Store { interface Store {
@@ -110,11 +119,23 @@ interface Store {
settings: Settings; settings: Settings;
updateSettings: (patch: Partial<Settings>) => void; updateSettings: (patch: Partial<Settings>) => void;
resetKeybinds: () => void; resetKeybinds: () => void;
// Folder helpers
addFolder: (name: string) => string;
removeFolder: (id: string) => void;
renameFolder: (id: string, name: string) => void;
toggleFolderTab: (id: string) => void;
assignMangaToFolder: (folderId: string, mangaId: number) => void;
removeMangaFromFolder: (folderId: string, mangaId: number) => void;
getMangaFolders: (mangaId: number) => Folder[];
}
function genId(): string {
return Math.random().toString(36).slice(2, 10);
} }
export const useStore = create<Store>()( export const useStore = create<Store>()(
persist( persist(
(set) => ({ (set, get) => ({
navPage: "library", navPage: "library",
setNavPage: (navPage) => set({ navPage }), setNavPage: (navPage) => set({ navPage }),
activeManga: null, activeManga: null,
@@ -152,6 +173,63 @@ export const useStore = create<Store>()(
set((s) => ({ settings: { ...s.settings, ...patch } })), set((s) => ({ settings: { ...s.settings, ...patch } })),
resetKeybinds: () => resetKeybinds: () =>
set((s) => ({ settings: { ...s.settings, keybinds: DEFAULT_KEYBINDS } })), set((s) => ({ settings: { ...s.settings, keybinds: DEFAULT_KEYBINDS } })),
// ── Folder actions ──────────────────────────────────────────────────────
addFolder: (name) => {
const id = genId();
set((s) => ({
settings: {
...s.settings,
folders: [...s.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }],
},
}));
return id;
},
removeFolder: (id) =>
set((s) => ({
settings: {
...s.settings,
folders: s.settings.folders.filter((f) => f.id !== id),
},
})),
renameFolder: (id, name) =>
set((s) => ({
settings: {
...s.settings,
folders: s.settings.folders.map((f) => f.id === id ? { ...f, name: name.trim() } : f),
},
})),
toggleFolderTab: (id) =>
set((s) => ({
settings: {
...s.settings,
folders: s.settings.folders.map((f) => f.id === id ? { ...f, showTab: !f.showTab } : f),
},
})),
assignMangaToFolder: (folderId, mangaId) =>
set((s) => ({
settings: {
...s.settings,
folders: s.settings.folders.map((f) =>
f.id === folderId && !f.mangaIds.includes(mangaId)
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
: f
),
},
})),
removeMangaFromFolder: (folderId, mangaId) =>
set((s) => ({
settings: {
...s.settings,
folders: s.settings.folders.map((f) =>
f.id === folderId
? { ...f, mangaIds: f.mangaIds.filter((id) => id !== mangaId) }
: f
),
},
})),
getMangaFolders: (mangaId) =>
get().settings.folders.filter((f) => f.mangaIds.includes(mangaId)),
}), }),
{ {
name: "moku-store", name: "moku-store",
@@ -167,6 +245,7 @@ export const useStore = create<Store>()(
settings: { settings: {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...(persisted as any)?.settings, ...(persisted as any)?.settings,
folders: (persisted as any)?.settings?.folders ?? [],
keybinds: { keybinds: {
...DEFAULT_KEYBINDS, ...DEFAULT_KEYBINDS,
...(persisted as any)?.settings?.keybinds, ...(persisted as any)?.settings?.keybinds,