// tomugi-series.jsx — series detail page focused on organizing volumes // One color PER SERIES for the generic volume books. Prefer the AniList cover's // dominant color so the book matches the real cover; fall back to a stable // generated mid-tone when AniList didn't provide one. function seriesBookTint(series) { let base; if (series && series.coverColor) { base = series.coverColor; } else { const s = String((series && (series.id || series.title)) || ""); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; base = `hsl(${h} 42% 40%)`; } // Mute toward the dark theme so the little books don't grab all the attention. return `color-mix(in srgb, ${base} 55%, #1c1f27)`; } function SeriesPage({ series, onBack, onOpen, onGenre, showTexture = true }) { const { useState } = React; const { buildVolumes, recommendSeries } = window.TomugiUtils; const volumes = buildVolumes(series); const recommendations = onOpen ? recommendSeries(series, window.TOMUGI_SERIES, 6) : []; const [openVolumes, setOpenVolumes] = useState(() => new Set()); const [organized, setOrganized] = useState(() => new Set(volumes.filter((v) => v.organized).map((v) => v.n))); const [modalTarget, setModalTarget] = useState(null); const [noticeOpen, setNoticeOpen] = useState(false); const [descOpen, setDescOpen] = useState(false); // Group volumes into drawers of GROUP_SIZE. The last drawer absorbs a small // remainder instead of leaving a tiny trailing group (e.g. 32 -> 10, 10, 12). const GROUP_SIZE = 10; const groups = []; for (let i = 0; i < volumes.length; i += GROUP_SIZE) groups.push(volumes.slice(i, i + GROUP_SIZE)); if (groups.length > 1 && groups[groups.length - 1].length < GROUP_SIZE / 2) { const tail = groups.pop(); groups[groups.length - 1] = groups[groups.length - 1].concat(tail); } const grouped = groups.length > 1; const [openGroups, setOpenGroups] = useState(() => grouped ? new Set([0]) : new Set()); function toggleGroup(i) { setOpenGroups((prev) => { const next = new Set(prev); if (next.has(i)) next.delete(i); else next.add(i); return next; }); } function toggleVolume(n) { setOpenVolumes((prev) => { const next = new Set(prev); if (next.has(n)) next.delete(n); else next.add(n); return next; }); } function openOrganizer(target) { // Device/browser gate temporarily disabled for testing the organizer. setModalTarget(target); } function markOrganized(target) { setModalTarget(null); setOrganized((prev) => { if (target.kind === "all") return new Set(volumes.map((v) => v.n)); const next = new Set(prev); next.add(target.volume.n); return next; }); } const organizedCount = organized.size; const pendingCount = Math.max(0, volumes.length - organizedCount); const modalTitle = modalTarget?.kind === "volume" ? `${series.title} · Vol. ${String(modalTarget.volume.n).padStart(2, "0")}` : series.title; const modalChaps = modalTarget?.kind === "volume" ? modalTarget.volume.count : series.chaps; const modalPerVolume = modalTarget?.kind === "volume" ? modalTarget.volume.count : Math.ceil(series.chaps / series.vols); const renderVolume = (v) => { const done = organized.has(v.n); const isOpen = openVolumes.has(v.n); const items = v.chapters.length ? v.chapters.map((c) => ({ key: `ch-${c.num}`, label: `Chapter ${c.num}`, file: `Ch_${String(c.num).padStart(3, "0")}.cbz` })) : (v.files.length ? v.files.map((file, index) => ({ key: `file-${index}`, label: file, file: "ready" })) : [{ key: "volume", label: `Volume ${v.n}`, file: done ? "organized" : "pending" }]); return (
Automatic organizing isn't supported on phones or tablets yet. Please open tomugi on a computer (laptop or desktop) using Google Chrome or Microsoft Edge to organize your collection.