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:
+1
-1
@@ -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.
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -6,30 +6,40 @@ import { useStore, type NavPage } from "../../store";
|
|||||||
import s from "./Sidebar.module.css";
|
import s from "./Sidebar.module.css";
|
||||||
|
|
||||||
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [
|
||||||
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
{ id: "library", icon: <Books size={18} weight="light" />, label: "Library" },
|
||||||
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
{ id: "search", icon: <MagnifyingGlass size={18} weight="light" />, label: "Search" },
|
||||||
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
{ id: "history", icon: <ClockCounterClockwise size={18} weight="light" />, label: "History" },
|
||||||
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
{ id: "sources", icon: <Compass size={18} weight="light" />, label: "Sources" },
|
||||||
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
{ id: "downloads", icon: <DownloadSimple size={18} weight="light" />, label: "Downloads" },
|
||||||
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
{ id: "extensions", icon: <PuzzlePiece size={18} weight="light" />, label: "Extensions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
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 openSettings = useStore((state) => state.openSettings);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
|
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
||||||
|
const openSettings = useStore((state) => state.openSettings);
|
||||||
|
|
||||||
function navigate(id: NavPage) {
|
function navigate(id: NavPage) {
|
||||||
setNavPage(id);
|
setNavPage(id);
|
||||||
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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -51,16 +50,15 @@ export default function Library() {
|
|||||||
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
|
||||||
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((state) => state.setActiveManga);
|
const setActiveManga = useStore((state) => state.setActiveManga);
|
||||||
const libraryFilter = useStore((state) => state.libraryFilter);
|
const libraryFilter = useStore((state) => state.libraryFilter);
|
||||||
const setLibraryFilter = useStore((state) => state.setLibraryFilter);
|
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 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
|
||||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
? removeFromLibrary(m)
|
||||||
.catch(console.error),
|
: 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 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(() => {
|
||||||
all: allManga.length,
|
const result: Record<string, number> = {
|
||||||
library: allManga.filter((m) => m.inLibrary).length,
|
all: allManga.length,
|
||||||
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
library: allManga.filter((m) => m.inLibrary).length,
|
||||||
}), [allManga]);
|
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 (
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user