diff --git a/src/components/pages/Home.svelte b/src/components/pages/Home.svelte index 81ac49e..3691eff 100644 --- a/src/components/pages/Home.svelte +++ b/src/components/pages/Home.svelte @@ -2,19 +2,19 @@ import { onMount, onDestroy } from "svelte"; import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, - CalendarBlank, CheckCircle, Star, PushPin, X as XIcon, - MagnifyingGlass, + CalendarBlank, CheckCircle, PushPin, X as XIcon, + MagnifyingGlass, ListBullets, } from "phosphor-svelte"; import { gql, thumbUrl } from "../../lib/client"; - import { GET_LIBRARY, GET_MANGA } from "../../lib/queries"; + import { GET_LIBRARY, GET_CHAPTERS } from "../../lib/queries"; import { cache, CACHE_KEYS } from "../../lib/cache"; import { history, readingStats, settings, activeManga, navPage, previewManga, openReader, activeChapterList, - COMPLETED_FOLDER_ID, setHeroSlot, updateSettings, + COMPLETED_FOLDER_ID, setHeroSlot, } from "../../store"; import type { HistoryEntry } from "../../store"; - import type { Manga } from "../../lib/types"; + import type { Manga, Chapter } from "../../lib/types"; // ── Helpers ─────────────────────────────────────────────────────────────────── function timeAgo(ts: number): string { @@ -27,16 +27,16 @@ if (d < 7) return `${d}d ago`; return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); } - function formatReadTime(m: number): string { - if (m < 60) return `${m}m`; - const h = Math.floor(m / 60), r = m % 60; + function formatReadTime(mins: number): string { + if (mins < 60) return `${mins}m`; + const h = Math.floor(mins / 60), r = mins % 60; return r === 0 ? `${h}h` : `${h}h ${r}m`; } + function focusEl(node: HTMLElement) { node.focus(); } - // ── Library data — loaded once ──────────────────────────────────────────────── + // ── Library ─────────────────────────────────────────────────────────────────── let libraryManga: Manga[] = []; let loadingLibrary = true; - let searchQuery = ""; onMount(() => { cache.get(CACHE_KEYS.LIBRARY, () => @@ -46,7 +46,7 @@ .finally(() => loadingLibrary = false); }); - // ── Continue reading — deduped by manga ─────────────────────────────────────── + // ── Continue reading (deduped) ──────────────────────────────────────────────── $: continueReading = (() => { const seen = new Set(); const out: HistoryEntry[] = []; @@ -60,197 +60,219 @@ })(); // ── Hero slots ──────────────────────────────────────────────────────────────── - // Slot 0: always auto (first continue-reading entry, not pinnable) - // Slots 1-3: pinned mangaId OR auto (next continue-reading entries) const TOTAL_SLOTS = 4; - interface HeroSlot { kind: "continue" | "pinned" | "empty"; - entry?: HistoryEntry; // for "continue" - manga?: Manga; // for "pinned" + entry?: HistoryEntry; + manga?: Manga; slotIndex: number; } $: resolvedSlots = (() => { const pins = $settings.heroSlots ?? [null, null, null, null]; const slots: HeroSlot[] = []; - - // Slot 0 — always continue reading const first = continueReading[0]; slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } - : { kind: "empty", slotIndex: 0 } - ); - - // Slots 1-3 - let historyIdx = 1; // which continueReading entry to use for auto + : { kind: "empty", slotIndex: 0 }); + let hi = 1; for (let i = 1; i < TOTAL_SLOTS; i++) { const pinId = pins[i]; - if (pinId !== null && pinId !== undefined) { + if (pinId != null) { const manga = libraryManga.find(m => m.id === pinId); if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; } } - // Auto — use next recent history entry - const entry = continueReading[historyIdx]; - historyIdx++; + const entry = continueReading[hi++]; slots.push(entry ? { kind: "continue", entry, slotIndex: i } - : { kind: "empty", slotIndex: i } - ); + : { kind: "empty", slotIndex: i }); } return slots; })(); - // ── Active hero index ───────────────────────────────────────────────────────── let activeIdx = 0; $: activeSlot = resolvedSlots[activeIdx]; + $: heroThumb = activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") + : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : ""; + $: heroTitle = activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") + : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""; + $: heroManga = activeSlot?.kind === "pinned" ? activeSlot.manga + : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null; + $: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null; + $: heroMangaId = heroEntry?.mangaId ?? heroManga?.id ?? null; - // ── Manga detail for active pinned slot ─────────────────────────────────────── - // For "continue" slots we have thumbnailUrl from history. - // For "pinned" slots we have the full Manga object. - $: heroThumb = activeSlot?.kind === "pinned" - ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") - : activeSlot?.kind === "continue" - ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") - : ""; + function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; } + function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; } + function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; } } - $: heroTitle = activeSlot?.kind === "pinned" - ? activeSlot.manga?.title ?? "" - : activeSlot?.kind === "continue" - ? activeSlot.entry?.mangaTitle ?? "" - : ""; - - $: heroManga = activeSlot?.kind === "pinned" - ? activeSlot.manga - : activeSlot?.kind === "continue" - ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) - : null; - - $: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null; - - // Svelte action — focuses element on mount, avoiding the a11y autofocus warning - function focusEl(node: HTMLElement) { node.focus(); } - - function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; } - function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; } - function goToSlot(i: number) { activeIdx = i; } - - // Keyboard: left/right arrow keys when no modal is open function onKey(e: KeyboardEvent) { - if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-section"))) return; + if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return; if (e.key === "ArrowRight") cycleNext(); if (e.key === "ArrowLeft") cyclePrev(); } onMount(() => window.addEventListener("keydown", onKey)); onDestroy(() => window.removeEventListener("keydown", onKey)); - // ── Slot picker (pin / unpin) ───────────────────────────────────────────────── - let pickerOpen = false; - let pickerSlotIndex: 1 | 2 | 3 | null = null; - let pickerSearch = ""; + // ── Hero chapter panel ──────────────────────────────────────────────────────── + // Load chapters for the active slot's manga, show 3-5 starting at where user left off + let heroChapters: Chapter[] = []; + let loadingHeroChapters = false; + let heroChaptersFor: number | null = null; + $: if (heroMangaId && heroMangaId !== heroChaptersFor) { + loadHeroChapters(heroMangaId); + } + + async function loadHeroChapters(mangaId: number) { + heroChaptersFor = mangaId; + loadingHeroChapters = true; + heroChapters = []; + try { + const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }); + if (heroChaptersFor !== mangaId) return; // stale + const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + // Find the chapter user left off on, show from one before it + const lastReadIdx = heroEntry + ? all.findIndex(c => c.id === heroEntry!.chapterId) + : all.findLastIndex(c => c.isRead); + const startIdx = Math.max(0, lastReadIdx); + heroChapters = all.slice(startIdx, startIdx + 5); + } catch { heroChapters = []; } + finally { loadingHeroChapters = false; } + } + + // ── Resume helpers ──────────────────────────────────────────────────────────── + let resuming = false; + + async function openChapter(chapter: Chapter) { + if (!heroMangaId) return; + resuming = true; + try { + let all = heroChapters; + if (!all.length) { + const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId }); + all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + } + openReader(chapter, all); + } catch { + activeManga.set({ id: heroMangaId, title: heroTitle, thumbnailUrl: (heroManga?.thumbnailUrl ?? "") } as any); + } finally { resuming = false; } + } + + async function resumeActive() { + if (!heroEntry && heroManga) { activeManga.set(heroManga); return; } + if (!heroEntry) return; + // Use hero chapter panel data if available (already fetched) + const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0]; + if (target && heroChapters.length) { await openChapter(target); return; } + // Fallback — fetch + resuming = true; + try { + const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId }); + const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0]; + if (ch) openReader(ch, chapters); + else activeManga.set({ id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any); + } catch { + activeManga.set({ id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any); + } finally { resuming = false; } + } + + async function resumeEntry(entry: HistoryEntry) { + try { + const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId }); + const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0]; + if (ch) openReader(ch, chapters); + else activeManga.set({ id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any); + } catch { + activeManga.set({ id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any); + } + } + + // ── Slot picker ─────────────────────────────────────────────────────────────── + let pickerOpen = false; + let pickerSlotIndex: 1|2|3|null = null; + let pickerSearch = ""; $: pickerResults = pickerSearch.trim() ? libraryManga.filter(m => m.title.toLowerCase().includes(pickerSearch.toLowerCase())).slice(0, 20) : libraryManga.slice(0, 20); - - function openPicker(slotIndex: 1 | 2 | 3) { - pickerSlotIndex = slotIndex; - pickerOpen = true; - pickerSearch = ""; - } + function openPicker(i: 1|2|3) { pickerSlotIndex = i; pickerOpen = true; pickerSearch = ""; } function closePicker() { pickerOpen = false; pickerSlotIndex = null; } + function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } } + function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); } - function pinManga(manga: Manga) { - if (pickerSlotIndex === null) return; - setHeroSlot(pickerSlotIndex, manga.id); - closePicker(); - } - function unpinSlot(i: 1 | 2 | 3) { - setHeroSlot(i, null); - } - - function resumeActive() { - if (!heroEntry && heroManga) { - activeManga.set(heroManga); - return; - } - if (!heroEntry) return; - const ch = $activeChapterList.find(c => c.id === heroEntry!.chapterId); - if (ch && $activeChapterList.length > 0) openReader(ch, $activeChapterList); - else activeManga.set({ id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any); - } - - // ── Recently completed ──────────────────────────────────────────────────────── - $: completedIds = $settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []; + // ── Completed, activity, stats ──────────────────────────────────────────────── + $: completedIds = $settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []; $: completedManga = completedIds.length > 0 - ? libraryManga.filter(m => completedIds.includes(m.id)).slice(0, 12) + ? libraryManga.filter(m => completedIds.includes(m.id)).slice(0, 10) : []; - - // ── Recent activity ─────────────────────────────────────────────────────────── $: recentHistory = $history.slice(0, 8); - - // ── Stats ───────────────────────────────────────────────────────────────────── $: stats = $readingStats; - $: hasStats = stats.totalChaptersRead > 0; + $: hasStats = stats.totalChaptersRead > 0 || stats.totalMangaRead > 0; function handleRowWheel(e: WheelEvent) { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - const el = e.currentTarget as HTMLElement; - e.stopPropagation(); el.scrollLeft += e.deltaY; + (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; + e.stopPropagation(); }
- -
- -
+ +
- + {#if heroThumb}
{:else} -
+
{/if}
- -
+ +
+ - -
+ +
{#if activeSlot?.kind === "empty"} -

Slot empty

+

Nothing here yet

{activeSlot.slotIndex === 0 - ? "Start reading a manga to see it here" - : "Pin a manga or read more to fill this slot"} + ? "Read a manga to see it here" + : "Pin a manga or keep reading to fill this slot"}

{#if activeSlot.slotIndex !== 0} {/if} {:else} -
{#if activeSlot?.kind === "continue"} - Reading + Reading {:else} - Pinned + Pinned {/if} {#each (heroManga?.genre ?? []).slice(0, 3) as g} {g} @@ -264,11 +286,11 @@ {/if} {#if heroEntry} -

- +

+ {heroEntry.chapterName} - {#if heroEntry.pageNumber > 1}· p.{heroEntry.pageNumber}{/if} - {timeAgo(heroEntry.readAt)} + {#if heroEntry.pageNumber > 1} · p.{heroEntry.pageNumber}{/if} + {timeAgo(heroEntry.readAt)}

{/if} @@ -276,186 +298,262 @@

{heroManga.description}

{/if} -
{#if activeSlot?.kind === "continue"} - - {:else} - {/if} {#if activeSlot?.slotIndex !== 0} {#if activeSlot?.kind === "pinned"} {:else} {/if} {/if}
{/if} + + +
+ +
+ {#each resolvedSlots as slot, i} + + {/each} +
+ + {activeIdx + 1}/{TOTAL_SLOTS} +
- - - + +
+
+ + Up Next +
- -
- {#each resolvedSlots as slot, i} - - {/each} + {#if activeSlot?.kind === "empty"} +

No chapters to show

+ {:else if loadingHeroChapters} + {#each Array(4) as _} +
+
+
+
+
+
+
+ {/each} + {:else if heroChapters.length === 0} +

No chapters available

+ {:else} + {#each heroChapters as ch (ch.id)} + {@const isCurrent = heroEntry?.chapterId === ch.id} + + {/each} + {#if heroManga} + + {/if} + {/if}
- -
{activeIdx + 1} / {TOTAL_SLOTS}
-
- - {#if hasStats} -
-
- - {stats.currentStreakDays} - day streak -
-
-
- - {stats.totalChaptersRead} - chapters -
-
-
- - {formatReadTime(stats.totalMinutesRead)} - read time -
-
-
- - {stats.totalMangaRead} - series -
-
-
- - {stats.longestStreakDays}d - best streak -
-
- {/if} - - - {#if completedIds.length > 0 && completedManga.length > 0} -
-
- Recently Completed - -
-
- {#each completedManga as m (m.id)} - - {/each} - -
-
- {/if} - - + {#if recentHistory.length > 0}
- Recent Activity - + Recent Activity +
{#each recentHistory as entry (entry.chapterId)} - {/each}
{:else} -
-

Start reading to see activity here

+
+

Start reading to build your activity feed

{/if} + +
+ + +
+ {#if completedManga.length > 0} +
+ Completed + +
+
+ {#each completedManga as m (m.id)} + + {/each} +
+ {:else} +
+ Completed +
+

Finish a manga to see it here

+ {/if} +
+ + +
+
+ Your Stats +
+ {#if hasStats} +
+
+
+
+ {stats.currentStreakDays} + Day streak +
+
+
+
+
+ {stats.totalChaptersRead} + Chapters read +
+
+
+
+
+ {formatReadTime(stats.totalMinutesRead)} + Total read time +
+
+
+
+
+ {stats.totalMangaRead} + Series started +
+
+
+
+
+ {completedIds.length} + Completed +
+
+
+
+
+ {stats.longestStreakDays}d + Best streak +
+
+
+ {:else} +

Start reading to see your stats

+ {/if} +
+
+
- + {#if pickerOpen}
- Pin manga to slot {(pickerSlotIndex ?? 0) + 1} - + Pin manga — slot {(pickerSlotIndex ?? 0) + 1} +
- +
{#if loadingLibrary} -

Loading library…

+

Loading…

{:else if pickerResults.length === 0}

No results

{:else} {#each pickerResults as m (m.id)} {/each} {/if} @@ -466,236 +564,304 @@