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}
+
+ showTip(e, cell)}
+ onmouseleave={hideTip}
+ aria-label="{cell.count} chapters on {cell.dateStr}"
+ >
+ {/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"); }}
- />
+
-
+
+
+
+
@@ -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 @@
+
+
+
+
+
+
+ {#if loading}
+
Loading…
+ {:else if visibleRecs.length > 0}
+
+ {#each visibleRecs as r (r.manga.id)}
+
onopenrecommended(r.manga)}>
+
+
+ {/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 @@
-
-
-
-
- {#if updates.length > 0}
-
- {: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: {} };
}