diff --git a/Todo b/Todo index a5bc947..19ac97f 100644 --- a/Todo +++ b/Todo @@ -8,7 +8,6 @@ Minor Revisions: - Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off) - Priority Bugs: - Fix Library-Refresh System (TESTING) @@ -16,17 +15,6 @@ Priority Bugs: - Allow User to Wipe Suwayomi (Scratch) - If Possible, Component based Wipe (Library, Etc) - - Remove RecentActivity from Home & Replace with Library Tag Filtering + Discover - - Add Item-Detection for Updates, hence when Updates = 0 replace with RecentActivity (Layout Same). - - ActivityHeatmap (Like Github), but for Moku. - -General/Misc Bugs: - - Fix Highlightable Elements - - Investigate "egl:failed to create dri2 screen" - - Check Fonts/Design on Flatpak - - Fix Delete-All Crash (Deletes All but Cripples App) - - Investigate Prod vs. Dev Directory Change/Data Load (Caused by Library Algorithm Revision?) - In-Progress: - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) @@ -49,7 +37,7 @@ In-Progress: - Tracking Revamp - Completely Revamp Tracking - - Fix ALl Folder Tabs (Works in Dev, not Prod) + - Fix ALl Folder Tabs (Works in Dev, not Prod) (TESTING) - Extensions - Library - Search @@ -57,6 +45,18 @@ In-Progress: - Fix Tracking Login - Pasting OAuth URL is not User-Friendly, Look for Alternatives +- Fix Home Layout & Scaling + - Layout constrains UI, Hero-Card Disappears? + - Home Layout itself is currently off. + - Add Updates Substitute, Derived from One Source + Genre Tags (Check Library for Tags) + - ActivityHeatmap needs Proper Constraints for Month Namescd + +- MacOS Fixes + - Revamp Server-State Check (MacOS does not pick up) + - Check Moku Sidebar Icon (Looks ugly on MacOS) + - Icon appears as a Square + - Icon appears to have Green Underglow? + Testing Bugs: - Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN diff --git a/src/core/auth.ts b/src/core/auth.ts index 40f72b9..3248258 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -58,7 +58,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport const res = await fetch(`${base}/api/graphql`, { method: "POST", credentials: "omit", headers, body: JSON.stringify({ query: "{ __typename }" }), - signal: AbortSignal.timeout(2000), + signal: AbortSignal.timeout(5000), }); if (res.ok) return "ok"; if (res.status === 401) { @@ -76,4 +76,4 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport } return "unreachable"; } catch { return "unreachable"; } -} +} \ No newline at end of file diff --git a/src/features/home/components/ActivityHeatmap.svelte b/src/features/home/components/ActivityHeatmap.svelte new file mode 100644 index 0000000..34d82cd --- /dev/null +++ b/src/features/home/components/ActivityHeatmap.svelte @@ -0,0 +1,281 @@ + + +
+ +
+
+
+ {#each visibleWeeks as _week, ci} + {@const lbl = monthLabels.find(l => l.colIndex === ci)} +
{lbl?.label ?? ""}
+ {/each} +
+
+ +
+
+ {#each DAY_LABELS as d} + {d} + {/each} +
+
+ {#each visibleWeeks as week} +
+ {#each week as cell} + + + {/each} +
+ {/each} +
+
+ +
+ Less + {#each [0, 1, 2, 3, 4] as lvl} +
+ {/each} + More +
+ +
+ +{#if tip} +
{tip.text}
+{/if} + + \ No newline at end of file diff --git a/src/features/home/components/Home.svelte b/src/features/home/components/Home.svelte index 28d6137..45f2e7b 100644 --- a/src/features/home/components/Home.svelte +++ b/src/features/home/components/Home.svelte @@ -12,7 +12,8 @@ import HeroStage from "./HeroStage.svelte"; import HeroSlotPicker from "./HeroSlotPicker.svelte"; import ActivityFeed from "./ActivityFeed.svelte"; - import UpdatesRow from "./UpdatesRow.svelte"; + import ActivityHeatmap from "./ActivityHeatmap.svelte"; + import RecsRow from "./RecsRow.svelte"; import StatsGrid from "./StatsGrid.svelte"; let libraryManga: Manga[] = $state([]); @@ -223,44 +224,59 @@
- { if (heroManga) store.activeManga = heroManga; }} - /> +
+ { if (heroManga) store.activeManga = heroManga; }} + /> +
- setNavPage("history")} - onopenlibrary={() => setNavPage("library")} - /> +
+
+
+ setNavPage("history")} + onopenlibrary={() => setNavPage("library")} + /> +
+
+
+ { store.previewManga = m; }} + /> +
+
- { if (m) store.previewManga = m; }} - onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }} - /> +
+ Activity + +
- +
+ +
+
@@ -288,19 +304,65 @@ flex: 1; display: flex; flex-direction: column; + overflow: hidden; + min-height: 0; + } + .hero-shrink-guard { flex-shrink: 0; } + .scroll-body { + flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; + scrollbar-width: none; } + .scroll-body::-webkit-scrollbar { display: none; } + + .mid-row { + display: grid; + grid-template-columns: 1fr 1px 1.4fr; + border-top: 1px solid var(--border-dim); + flex-shrink: 0; + min-height: 0; + } + .mid-left { + min-width: 0; + overflow: hidden; + } + /* suppress ActivityFeed's own border-top — mid-row provides it */ + .mid-left :global(.section) { border-top: none; } + .mid-divider { background: var(--border-dim); align-self: stretch; } + .mid-right { + min-width: 0; + overflow: hidden; + padding: var(--sp-3) var(--sp-4) var(--sp-4); + } + .bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; - padding: var(--sp-4) var(--sp-4) var(--sp-5); border-top: 1px solid var(--border-dim); - gap: var(--sp-4); flex-shrink: 0; } .bottom-divider { background: var(--border-dim); align-self: stretch; } + .bottom-heatmap { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-4) var(--sp-4) var(--sp-5); + min-width: 0; + } + .bottom-stats { + padding: var(--sp-4) var(--sp-4) var(--sp-5); + min-width: 0; + overflow: hidden; + } + .bottom-label { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + } @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } diff --git a/src/features/home/components/RecsRow.svelte b/src/features/home/components/RecsRow.svelte new file mode 100644 index 0000000..9b4d971 --- /dev/null +++ b/src/features/home/components/RecsRow.svelte @@ -0,0 +1,244 @@ + + +
+
+ + Recommended + + {#if genres.length > 1} +
+ + {activeGenre} + +
+ {/if} +
+ +
+ {#if loading} +

Loading…

+ {:else if visibleRecs.length > 0} +
+ {#each visibleRecs as r (r.manga.id)} + + {/each} +
+ {:else} +

No recommendations found

+ {/if} +
+
+ + \ No newline at end of file diff --git a/src/features/home/components/StatsGrid.svelte b/src/features/home/components/StatsGrid.svelte index a60f201..ea58a11 100644 --- a/src/features/home/components/StatsGrid.svelte +++ b/src/features/home/components/StatsGrid.svelte @@ -1,6 +1,7 @@ - -
-
- - Updates - {#if lastRefresh}{timeAgoRefresh(lastRefresh)}{/if} - - {#if updates.length > 0} - - {/if} -
- - {#if updates.length > 0} -
{ e.preventDefault(); handleRowWheel(e); }}> - {#each updates as u (u.mangaId)} - {@const m = libraryManga.find(x => x.id === u.mangaId)} - - {/each} -
- {:else} -

{lastRefresh ? "No new chapters found" : "Check for updates in the library"}

- {/if} -
- - diff --git a/src/features/home/lib/recommendations.ts b/src/features/home/lib/recommendations.ts new file mode 100644 index 0000000..2b5eb01 --- /dev/null +++ b/src/features/home/lib/recommendations.ts @@ -0,0 +1,93 @@ +import { gql } from "@api/client"; +import { MANGAS_BY_GENRE } from "@api/queries/manga"; +import { buildTagFilter } from "@features/discover/lib/searchFilter"; +import type { Manga } from "@types"; +import type { HistoryEntry } from "@store/state.svelte"; + +export interface RecommendedManga { + manga: Manga; + matchedGenres: string[]; +} + +const TOP_GENRES = 6; +const PAGE_SIZE = 100; +const MAX_PAGES = 5; + +export function topGenres(history: HistoryEntry[], libraryManga: Manga[]): string[] { + const byId = new Map(libraryManga.map(m => [m.id, m])); + const tally = new Map(); + + for (const entry of history) { + const manga = byId.get(entry.mangaId); + if (!manga?.genre?.length) continue; + for (const g of manga.genre) { + const key = g.toLowerCase(); + const existing = tally.get(key); + if (existing) { existing.count++; } + else { tally.set(key, { count: 1, original: g }); } + } + } + + return [...tally.values()] + .sort((a, b) => b.count - a.count) + .slice(0, TOP_GENRES) + .map(e => e.original); +} + +type Result = { mangas: { nodes: Manga[] } }; + +async function fetchGenrePages(genre: string, signal?: AbortSignal): Promise { + const filter = { + and: [ + buildTagFilter([genre], "OR", []), + { inLibrary: { equalTo: false } }, + ], + }; + + const pages = await Promise.all( + Array.from({ length: MAX_PAGES }, (_, i) => + gql(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal) + .then(d => d.mangas.nodes) + .catch(() => [] as Manga[]) + ) + ); + + const seen = new Set(); + const nodes: Manga[] = []; + for (const page of pages) { + if (!page.length) break; + for (const m of page) { + if (!seen.has(m.id)) { seen.add(m.id); nodes.push(m); } + } + if (page.length < PAGE_SIZE) break; + } + return nodes; +} + +export async function fetchRecommendations( + history: HistoryEntry[], + libraryManga: Manga[], + signal?: AbortSignal, +): Promise { + if (!history.length || !libraryManga.length) return []; + + const genres = topGenres(history, libraryManga); + if (!genres.length) return []; + + const perGenre = await Promise.all(genres.map(g => fetchGenrePages(g, signal))); + + const seen = new Set(); + const merged: Manga[] = []; + for (const page of perGenre) { + for (const m of page) { + if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } + } + } + + return merged.map(m => ({ + manga: m, + matchedGenres: (m.genre ?? []).filter(g => + genres.some(tg => tg.toLowerCase() === g.toLowerCase()) + ), + })); +} \ No newline at end of file diff --git a/src/features/reader/components/ReaderProgressBar.svelte b/src/features/reader/components/ReaderProgressBar.svelte index 97d6094..4fdb959 100644 --- a/src/features/reader/components/ReaderProgressBar.svelte +++ b/src/features/reader/components/ReaderProgressBar.svelte @@ -66,14 +66,14 @@
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id} - {@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber} - {@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0} + {@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber} + {@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
{/if} {#each activeChapterMarkers as m (m.id)} - {@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber} - {@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0} + {@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber} + {@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
{/each} diff --git a/src/shared/chrome/SplashScreen.svelte b/src/shared/chrome/SplashScreen.svelte index 5371e05..ae99048 100644 --- a/src/shared/chrome/SplashScreen.svelte +++ b/src/shared/chrome/SplashScreen.svelte @@ -113,7 +113,11 @@ }); $effect(() => { - if (!ringFull) return; + if (!ringFull) { + exitLock = false; + exiting = false; + return; + } cancelAnimationFrame(animFrame); ringProg = 1; if (lockEnabled && !pinUnlocked) { @@ -163,8 +167,6 @@ return () => clearInterval(dotsInterval); }); - // ── Canvas card animation ───────────────────────────────────────────────── - interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; } interface CardTrig { cosA: number; sinA: number; tiltRad: number; } interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; } @@ -177,7 +179,6 @@ const BUF = 80, COLS = 14; - // Deterministic per-index hash — no random(), same layout every mount function hash(n: number): number { let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b); x = Math.imul(x ^ (x >>> 16), 0x45d9f3b); @@ -275,7 +276,6 @@ for (let i = 0; i < cards.length; i++) { const c = cards[i]; const p = ((t / c.cycleSec) + c.phase) % 1; - // Fade in at entry, fade out at exit const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha; if (alpha < 0.005) continue; const cy = c.yStart - p * c.travel; diff --git a/src/store/boot.svelte.ts b/src/store/boot.svelte.ts index 29a4203..fde2a4b 100644 --- a/src/store/boot.svelte.ts +++ b/src/store/boot.svelte.ts @@ -1,7 +1,7 @@ import { store } from "@store/state.svelte"; import { probeServer, loginBasic } from "@core/auth"; -const MAX_ATTEMPTS = 10; +const MAX_ATTEMPTS = 40; export const boot = $state({ serverProbeOk: false, @@ -15,20 +15,20 @@ export const boot = $state({ loginBusy: false, }); -let cancelProbe = false; +let probeGeneration = 0; export function startProbe() { - cancelProbe = false; + const gen = ++probeGeneration; boot.failed = false; boot.loginRequired = false; boot.unsupportedMode = false; let tries = 0; async function probe() { - if (cancelProbe) return; + if (gen !== probeGeneration) return; tries++; const result = await probeServer(); - if (cancelProbe) return; + if (gen !== probeGeneration) return; if (result === "ok") { boot.serverProbeOk = true; @@ -59,14 +59,15 @@ export function startProbe() { } if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; } - setTimeout(probe, 750); + const delay = Math.min(750 + tries * 250, 3000); + setTimeout(probe, delay); } - setTimeout(probe, 800); + setTimeout(probe, 2000); } export function stopProbe() { - cancelProbe = true; + probeGeneration++; } export async function submitLogin(onSuccess: () => void) { @@ -99,7 +100,7 @@ export function retryBoot() { } export function bypassBoot(onReady: () => void) { - cancelProbe = true; + probeGeneration++; boot.serverProbeOk = true; boot.loginRequired = false; boot.unsupportedMode = false; diff --git a/src/store/state.svelte.ts b/src/store/state.svelte.ts index 601e44d..d847f08 100644 --- a/src/store/state.svelte.ts +++ b/src/store/state.svelte.ts @@ -224,6 +224,7 @@ class Store { markers: MarkerEntry[] = $state(saved?.markers ?? []); readLog: ReadLogEntry[] = $state(saved?.readLog ?? []); readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS }); + dailyReadCounts: Record = $state(saved?.dailyReadCounts ?? {}); searchCache: Map = $state(new Map()); searchLibraryIds: Set = $state(new Set()); searchSrcOffset: number = $state(0); @@ -250,6 +251,7 @@ class Store { settings: this.settings, history: this.history, bookmarks: this.bookmarks, markers: this.markers, readLog: this.readLog, readingStats: this.readingStats, + dailyReadCounts: this.dailyReadCounts, libraryUpdates: this.libraryUpdates, lastLibraryRefresh: this.lastLibraryRefresh, acknowledgedUpdateIds: [...this.acknowledgedUpdates], @@ -288,6 +290,8 @@ class Store { lastReadAt: entry.readAt, currentStreakDays: streak, longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr, }; + const dayKey = new Date().toISOString().slice(0, 10); + this.dailyReadCounts = { ...this.dailyReadCounts, [dayKey]: (this.dailyReadCounts[dayKey] ?? 0) + 1 }; } } @@ -314,7 +318,7 @@ class Store { getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); } getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); } clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId); } - clearHistory() { this.history = []; this.readLog = []; } + clearHistory() { this.history = []; this.readLog = []; this.dailyReadCounts = {}; } clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); @@ -329,6 +333,7 @@ class Store { wipeAllData() { this.history = []; this.readLog = []; this.markers = []; + this.dailyReadCounts = {}; this.readingStats = { ...DEFAULT_READING_STATS }; this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} }; }