mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Folder/Tag System
This commit is contained in:
@@ -15,29 +15,32 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: var(--sp-3);
|
||||
|
||||
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 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
background-color: var(--accent);
|
||||
|
||||
mask-image: url("../../assets/moku-icon.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
|
||||
-webkit-mask-image: url("../../assets/moku-icon.svg");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
|
||||
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -51,54 +54,28 @@
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.tabActive:hover {
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.tab:hover { color: var(--text-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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-2) 0;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
width: 100%; padding: var(--sp-3) var(--sp-2) 0;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.settingsBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-faint);
|
||||
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); }
|
||||
@@ -6,30 +6,40 @@ import { useStore, type NavPage } from "../../store";
|
||||
import s from "./Sidebar.module.css";
|
||||
|
||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
const navPage = useStore((state) => state.navPage);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||
const openSettings = useStore((state) => state.openSettings);
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
setNavPage(id);
|
||||
if (id !== "sources") setActiveSource(null);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
setNavPage("library");
|
||||
setActiveSource(null);
|
||||
setActiveManga(null);
|
||||
setLibraryFilter("library");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={s.root}>
|
||||
<div className={s.logo}>
|
||||
<div className={s.logoIcon} aria-label="Moku Logo" />
|
||||
</div>
|
||||
{/* Logo click → back to library root */}
|
||||
<button className={s.logo} onClick={goHome} title="Go to Library" aria-label="Go to Library">
|
||||
<div className={s.logoIcon} />
|
||||
</button>
|
||||
<nav className={s.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button key={tab.id} title={tab.label}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -371,4 +371,91 @@
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
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 { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Folder } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
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 }[] = [
|
||||
{ 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: "keybinds", label: "Keybinds", icon: <Keyboard 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" /> },
|
||||
];
|
||||
|
||||
@@ -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() {
|
||||
return (
|
||||
@@ -594,7 +717,6 @@ export default function SettingsModal() {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const contentBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to top on every tab switch
|
||||
useEffect(() => {
|
||||
contentBodyRef.current?.scrollTo({ top: 0 });
|
||||
}, [tab]);
|
||||
@@ -638,6 +760,7 @@ export default function SettingsModal() {
|
||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
|
||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||
{tab === "folders" && <FoldersTab />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+81
-2
@@ -5,7 +5,7 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
|
||||
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
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 ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
@@ -26,6 +26,13 @@ export interface ActiveDownload {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
mangaIds: number[];
|
||||
showTab: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
@@ -51,6 +58,7 @@ export interface Settings {
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
storageLimitGb: number | null;
|
||||
folders: Folder[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -78,6 +86,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
storageLimitGb: null,
|
||||
folders: [],
|
||||
};
|
||||
|
||||
interface Store {
|
||||
@@ -110,11 +119,23 @@ interface Store {
|
||||
settings: Settings;
|
||||
updateSettings: (patch: Partial<Settings>) => 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>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
navPage: "library",
|
||||
setNavPage: (navPage) => set({ navPage }),
|
||||
activeManga: null,
|
||||
@@ -152,6 +173,63 @@ export const useStore = create<Store>()(
|
||||
set((s) => ({ settings: { ...s.settings, ...patch } })),
|
||||
resetKeybinds: () =>
|
||||
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",
|
||||
@@ -167,6 +245,7 @@ export const useStore = create<Store>()(
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(persisted as any)?.settings,
|
||||
folders: (persisted as any)?.settings?.folders ?? [],
|
||||
keybinds: {
|
||||
...DEFAULT_KEYBINDS,
|
||||
...(persisted as any)?.settings?.keybinds,
|
||||
|
||||
Reference in New Issue
Block a user