mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Feat: Home Re-Design & MacOS Detection Fix
This commit is contained in:
@@ -8,7 +8,6 @@ Minor Revisions:
|
|||||||
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
- Look at how Manga are Organized in WebUI and Implement into Series-Detail (Chapter Display is Off)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Priority Bugs:
|
Priority Bugs:
|
||||||
- Fix Library-Refresh System (TESTING)
|
- Fix Library-Refresh System (TESTING)
|
||||||
|
|
||||||
@@ -16,17 +15,6 @@ Priority Bugs:
|
|||||||
- Allow User to Wipe Suwayomi (Scratch)
|
- Allow User to Wipe Suwayomi (Scratch)
|
||||||
- If Possible, Component based Wipe (Library, Etc)
|
- 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:
|
In-Progress:
|
||||||
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
|
||||||
@@ -49,7 +37,7 @@ In-Progress:
|
|||||||
- Tracking Revamp
|
- Tracking Revamp
|
||||||
- Completely Revamp Tracking
|
- Completely Revamp Tracking
|
||||||
|
|
||||||
- Fix ALl Folder Tabs (Works in Dev, not Prod)
|
- Fix ALl Folder Tabs (Works in Dev, not Prod) (TESTING)
|
||||||
- Extensions
|
- Extensions
|
||||||
- Library
|
- Library
|
||||||
- Search
|
- Search
|
||||||
@@ -57,6 +45,18 @@ In-Progress:
|
|||||||
- Fix Tracking Login
|
- Fix Tracking Login
|
||||||
- Pasting OAuth URL is not User-Friendly, Look for Alternatives
|
- 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:
|
Testing Bugs:
|
||||||
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
|
- Reader Zoom does not work (Dropdown Slider, Value Adjustment); Goes to NaN
|
||||||
|
|||||||
+2
-2
@@ -58,7 +58,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
|||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST", credentials: "omit", headers,
|
method: "POST", credentials: "omit", headers,
|
||||||
body: JSON.stringify({ query: "{ __typename }" }),
|
body: JSON.stringify({ query: "{ __typename }" }),
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (res.ok) return "ok";
|
if (res.ok) return "ok";
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@@ -76,4 +76,4 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
|
|||||||
}
|
}
|
||||||
return "unreachable";
|
return "unreachable";
|
||||||
} catch { return "unreachable"; }
|
} catch { return "unreachable"; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
dailyReadCounts,
|
||||||
|
}: {
|
||||||
|
dailyReadCounts: Record<string, number>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function intensity(count: number): 0 | 1 | 2 | 3 | 4 {
|
||||||
|
if (count === 0) return 0;
|
||||||
|
if (count === 1) return 1;
|
||||||
|
if (count <= 3) return 2;
|
||||||
|
if (count <= 6) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tip: { text: string; x: number; y: number } | null = $state(null);
|
||||||
|
|
||||||
|
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const label = cell.count === 0
|
||||||
|
? `No chapters — ${fmtDate(cell.dateStr)}`
|
||||||
|
: `${cell.count} chapter${cell.count !== 1 ? "s" : ""} — ${fmtDate(cell.dateStr)}`;
|
||||||
|
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTip() { tip = null; }
|
||||||
|
|
||||||
|
function fmtDate(d: string): string {
|
||||||
|
return new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapEl: HTMLElement;
|
||||||
|
let cellSize = $state(12);
|
||||||
|
let numWeeks = $state(26);
|
||||||
|
|
||||||
|
const GAP = 3;
|
||||||
|
const DAY_GUTTER = 28;
|
||||||
|
const LEGEND_H = 20;
|
||||||
|
const MONTH_H = 14;
|
||||||
|
const ROWS = 7;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!wrapEl) return;
|
||||||
|
const obs = new ResizeObserver(() => {
|
||||||
|
const h = wrapEl.clientHeight;
|
||||||
|
const w = wrapEl.clientWidth;
|
||||||
|
const cs = Math.max(8, Math.floor((h - LEGEND_H - MONTH_H - 2 * GAP - (ROWS - 1) * GAP) / ROWS));
|
||||||
|
cellSize = cs;
|
||||||
|
numWeeks = Math.max(4, Math.floor((w - DAY_GUTTER - GAP * 3) / (cs + GAP)));
|
||||||
|
});
|
||||||
|
obs.observe(wrapEl);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleWeeks = $derived((() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
const endDow = today.getDay(); // 0=Sun ... 6=Sat
|
||||||
|
const weekEnd = new Date(today);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + (6 - endDow)); // advance to Saturday
|
||||||
|
|
||||||
|
const weeks: { dateStr: string; count: number; isToday: boolean; isFuture: boolean }[][] = [];
|
||||||
|
for (let wi = numWeeks - 1; wi >= 0; wi--) {
|
||||||
|
const week: typeof weeks[0] = [];
|
||||||
|
for (let di = 0; di < 7; di++) {
|
||||||
|
const d = new Date(weekEnd);
|
||||||
|
d.setDate(d.getDate() - wi * 7 - (6 - di));
|
||||||
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
|
week.push({ dateStr, count: dailyReadCounts[dateStr] ?? 0, isToday: dateStr === todayStr, isFuture: d > today });
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const monthLabels = $derived((() => {
|
||||||
|
const labels: { label: string; colIndex: number }[] = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
visibleWeeks.forEach((week, ci) => {
|
||||||
|
const first = week[0];
|
||||||
|
if (!first) return;
|
||||||
|
const m = new Date(first.dateStr + "T00:00:00").getMonth();
|
||||||
|
if (m !== lastMonth) {
|
||||||
|
labels.push({ label: new Date(first.dateStr + "T00:00:00").toLocaleDateString("en-US", { month: "short" }), colIndex: ci });
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return labels;
|
||||||
|
})());
|
||||||
|
|
||||||
|
const DAY_LABELS = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="heatmap-wrap" bind:this={wrapEl} style="--cell:{cellSize}px; --cols:{numWeeks};">
|
||||||
|
|
||||||
|
<div class="month-row">
|
||||||
|
<div class="day-gutter"></div>
|
||||||
|
<div class="month-cells">
|
||||||
|
{#each visibleWeeks as _week, ci}
|
||||||
|
{@const lbl = monthLabels.find(l => l.colIndex === ci)}
|
||||||
|
<div class="month-label">{lbl?.label ?? ""}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="day-labels">
|
||||||
|
{#each DAY_LABELS as d}
|
||||||
|
<span class="day-label">{d}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="cell-grid">
|
||||||
|
{#each visibleWeeks as week}
|
||||||
|
<div class="week-col">
|
||||||
|
{#each week as cell}
|
||||||
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
|
<button
|
||||||
|
class="cell intensity-{intensity(cell.count)}"
|
||||||
|
class:cell-today={cell.isToday}
|
||||||
|
class:cell-future={cell.isFuture}
|
||||||
|
onmouseover={(e) => showTip(e, cell)}
|
||||||
|
onmouseleave={hideTip}
|
||||||
|
aria-label="{cell.count} chapters on {cell.dateStr}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-label">Less</span>
|
||||||
|
{#each [0, 1, 2, 3, 4] as lvl}
|
||||||
|
<div class="legend-cell intensity-{lvl}"></div>
|
||||||
|
{/each}
|
||||||
|
<span class="legend-label">More</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tip}
|
||||||
|
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.heatmap-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.day-gutter { width: 28px; flex-shrink: 0; }
|
||||||
|
.month-cells {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||||
|
gap: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.month-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding-left: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.day-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
.day-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
height: var(--cell);
|
||||||
|
line-height: var(--cell);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols), var(--cell));
|
||||||
|
gap: 3px;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
|
}
|
||||||
|
.week-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: var(--cell);
|
||||||
|
height: var(--cell);
|
||||||
|
border-radius: 3px;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter var(--t-fast), transform var(--t-fast);
|
||||||
|
}
|
||||||
|
.cell:hover:not(.cell-future) {
|
||||||
|
filter: brightness(1.5);
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intensity-0 { background: var(--bg-subtle); border: 1px solid var(--border-dim); }
|
||||||
|
.intensity-1 { background: var(--accent-muted); border: 1px solid var(--accent-dim); }
|
||||||
|
.intensity-2 { background: var(--accent-dim); border: 1px solid var(--accent); opacity: 0.7; }
|
||||||
|
.intensity-3 { background: var(--accent); border: 1px solid var(--accent-bright); opacity: 0.85; }
|
||||||
|
.intensity-4 { background: var(--accent-bright); border: 1px solid var(--accent-fg); }
|
||||||
|
|
||||||
|
.cell-today { outline: 1.5px solid var(--accent-fg); outline-offset: 1px; }
|
||||||
|
.cell-future { opacity: 0.2; cursor: default; pointer-events: none; }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.legend-cell {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.legend-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-tip {
|
||||||
|
position: fixed;
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
import HeroStage from "./HeroStage.svelte";
|
import HeroStage from "./HeroStage.svelte";
|
||||||
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
import HeroSlotPicker from "./HeroSlotPicker.svelte";
|
||||||
import ActivityFeed from "./ActivityFeed.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";
|
import StatsGrid from "./StatsGrid.svelte";
|
||||||
|
|
||||||
let libraryManga: Manga[] = $state([]);
|
let libraryManga: Manga[] = $state([]);
|
||||||
@@ -223,44 +224,59 @@
|
|||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="body">
|
<div class="body">
|
||||||
|
|
||||||
<HeroStage
|
<div class="hero-shrink-guard">
|
||||||
{resolvedSlots}
|
<HeroStage
|
||||||
bind:activeIdx
|
{resolvedSlots}
|
||||||
{heroThumb}
|
bind:activeIdx
|
||||||
{heroTitle}
|
{heroThumb}
|
||||||
{heroManga}
|
{heroTitle}
|
||||||
{heroEntry}
|
{heroManga}
|
||||||
{heroMangaId}
|
{heroEntry}
|
||||||
{heroChapters}
|
{heroMangaId}
|
||||||
{loadingHeroChapters}
|
{heroChapters}
|
||||||
{resuming}
|
{loadingHeroChapters}
|
||||||
onresume={resumeActive}
|
{resuming}
|
||||||
onopenchapter={openChapter}
|
onresume={resumeActive}
|
||||||
oncyclenext={cycleNext}
|
onopenchapter={openChapter}
|
||||||
oncycleprev={cyclePrev}
|
oncyclenext={cycleNext}
|
||||||
ongotoslot={goToSlot}
|
oncycleprev={cyclePrev}
|
||||||
onopenpicker={openPicker}
|
ongotoslot={goToSlot}
|
||||||
onunpin={unpinSlot}
|
onopenpicker={openPicker}
|
||||||
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
onunpin={unpinSlot}
|
||||||
/>
|
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ActivityFeed
|
<div class="scroll-body">
|
||||||
entries={recentHistory}
|
<div class="mid-row">
|
||||||
onresume={resumeEntry}
|
<div class="mid-left">
|
||||||
onviewhistory={() => setNavPage("history")}
|
<ActivityFeed
|
||||||
onopenlibrary={() => setNavPage("library")}
|
entries={recentHistory}
|
||||||
/>
|
onresume={resumeEntry}
|
||||||
|
onviewhistory={() => setNavPage("history")}
|
||||||
|
onopenlibrary={() => setNavPage("library")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mid-divider"></div>
|
||||||
|
<div class="mid-right">
|
||||||
|
<RecsRow
|
||||||
|
{libraryManga}
|
||||||
|
history={store.history}
|
||||||
|
onopenrecommended={(m) => { store.previewManga = m; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bottom-row">
|
<div class="bottom-row">
|
||||||
<UpdatesRow
|
<div class="bottom-heatmap">
|
||||||
updates={libraryUpdates}
|
<span class="bottom-label">Activity</span>
|
||||||
{libraryManga}
|
<ActivityHeatmap dailyReadCounts={store.dailyReadCounts} />
|
||||||
{lastRefresh}
|
</div>
|
||||||
onopen={(m) => { if (m) store.previewManga = m; }}
|
|
||||||
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
|
|
||||||
/>
|
|
||||||
<div class="bottom-divider"></div>
|
<div class="bottom-divider"></div>
|
||||||
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
<div class="bottom-stats">
|
||||||
|
<StatsGrid {stats} updateCount={libraryUpdates.length} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -288,19 +304,65 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.hero-shrink-guard { flex-shrink: 0; }
|
||||||
|
.scroll-body {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
min-height: 0;
|
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 {
|
.bottom-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1px 1fr;
|
grid-template-columns: 1fr 1px 1fr;
|
||||||
padding: var(--sp-4) var(--sp-4) var(--sp-5);
|
|
||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
gap: var(--sp-4);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bottom-divider { background: var(--border-dim); align-self: stretch; }
|
.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 {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
|
||||||
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
|
import type { Manga } from "@types";
|
||||||
|
import type { HistoryEntry } from "@store/state.svelte";
|
||||||
|
import { fetchRecommendations, topGenres } from "../lib/recommendations";
|
||||||
|
import type { RecommendedManga } from "../lib/recommendations";
|
||||||
|
|
||||||
|
let {
|
||||||
|
libraryManga,
|
||||||
|
history,
|
||||||
|
onopenrecommended,
|
||||||
|
}: {
|
||||||
|
libraryManga: Manga[];
|
||||||
|
history: HistoryEntry[];
|
||||||
|
onopenrecommended: (m: Manga) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const CARD_MIN_WIDTH = 100;
|
||||||
|
const GAP = 12;
|
||||||
|
const ROWS = 2;
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
containerWidth = entry.contentRect.width;
|
||||||
|
});
|
||||||
|
ro.observe(containerEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
|
||||||
|
const visibleCount = $derived(cols * ROWS);
|
||||||
|
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
|
||||||
|
|
||||||
|
let allRecs: RecommendedManga[] = $state([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let _ctrl: AbortController | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const _history = history;
|
||||||
|
const _library = libraryManga;
|
||||||
|
if (!_history.length || !_library.length) { allRecs = []; return; }
|
||||||
|
_ctrl?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
_ctrl = ctrl;
|
||||||
|
loading = true;
|
||||||
|
fetchRecommendations(_history, _library, ctrl.signal)
|
||||||
|
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
|
||||||
|
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const genres = $derived(topGenres(history, libraryManga));
|
||||||
|
|
||||||
|
let genreIdx = $state(0);
|
||||||
|
|
||||||
|
const activeGenre = $derived(genres[genreIdx] ?? null);
|
||||||
|
|
||||||
|
const visibleRecs = $derived(
|
||||||
|
(activeGenre
|
||||||
|
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
|
||||||
|
: allRecs
|
||||||
|
).slice(0, visibleCount)
|
||||||
|
);
|
||||||
|
|
||||||
|
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
|
||||||
|
function next() { genreIdx = (genreIdx + 1) % genres.length; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header">
|
||||||
|
<span class="col-title">
|
||||||
|
<Sparkle size={10} weight="bold" /> Recommended
|
||||||
|
</span>
|
||||||
|
{#if genres.length > 1}
|
||||||
|
<div class="genre-switcher">
|
||||||
|
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
|
||||||
|
<span class="genre-label">{activeGenre}</span>
|
||||||
|
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-container" bind:this={containerEl}>
|
||||||
|
{#if loading}
|
||||||
|
<p class="empty-msg">Loading…</p>
|
||||||
|
{:else if visibleRecs.length > 0}
|
||||||
|
<div class="card-grid" style={gridStyle}>
|
||||||
|
{#each visibleRecs as r (r.manga.id)}
|
||||||
|
<button class="card" onclick={() => onopenrecommended(r.manga)}>
|
||||||
|
<div class="card-cover-wrap">
|
||||||
|
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
|
||||||
|
<div class="card-gradient"></div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<p class="card-title">{r.manga.title}</p>
|
||||||
|
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty-msg">No recommendations found</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
|
||||||
|
|
||||||
|
.col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.genre-label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.nav-btn:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.grid-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, auto);
|
||||||
|
grid-auto-rows: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
||||||
|
|
||||||
|
.card-cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
:global(.card-cover) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: filter 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.card-gradient {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
.card-badge {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 9px;
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
padding: var(--sp-1) 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
|
||||||
import { formatReadTime } from "../lib/homeHelpers";
|
import { formatReadTime } from "../lib/homeHelpers";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
stats,
|
stats,
|
||||||
updateCount,
|
updateCount,
|
||||||
@@ -129,4 +130,4 @@
|
|||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Bell, ArrowRight } from "phosphor-svelte";
|
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
|
||||||
import type { Manga } from "@types";
|
|
||||||
import { timeAgoRefresh, handleRowWheel } from "../lib/homeHelpers";
|
|
||||||
|
|
||||||
interface LibraryUpdate {
|
|
||||||
mangaId: number;
|
|
||||||
mangaTitle: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
newChapters: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
updates,
|
|
||||||
libraryManga,
|
|
||||||
lastRefresh,
|
|
||||||
onopen,
|
|
||||||
onclear,
|
|
||||||
}: {
|
|
||||||
updates: LibraryUpdate[];
|
|
||||||
libraryManga: Manga[];
|
|
||||||
lastRefresh: number;
|
|
||||||
onopen: (m: Manga | undefined) => void;
|
|
||||||
onclear: () => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<div class="col-header">
|
|
||||||
<span class="col-title">
|
|
||||||
<Bell size={10} weight="bold" /> Updates
|
|
||||||
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
|
|
||||||
</span>
|
|
||||||
{#if updates.length > 0}
|
|
||||||
<button class="action-btn" onclick={onclear}>
|
|
||||||
Clear <ArrowRight size={9} weight="bold" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if updates.length > 0}
|
|
||||||
<div class="scroll-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
|
|
||||||
{#each updates as u (u.mangaId)}
|
|
||||||
{@const m = libraryManga.find(x => x.id === u.mangaId)}
|
|
||||||
<button class="card" onclick={() => onopen(m)}>
|
|
||||||
<div class="card-cover-wrap">
|
|
||||||
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="card-cover" />
|
|
||||||
<div class="card-gradient"></div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="card-title">{u.mangaTitle}</p>
|
|
||||||
<p class="card-badge">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="empty-msg">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.col { display: flex; flex-direction: column; min-width: 0; }
|
|
||||||
|
|
||||||
.col-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-bottom: var(--sp-2);
|
|
||||||
}
|
|
||||||
.col-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.refresh-age {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-left: var(--sp-2);
|
|
||||||
}
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-faint);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
.action-btn:hover { color: var(--accent-fg); }
|
|
||||||
|
|
||||||
.scroll-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-bottom: var(--sp-1);
|
|
||||||
}
|
|
||||||
.scroll-row::-webkit-scrollbar { display: none; }
|
|
||||||
|
|
||||||
.card {
|
|
||||||
flex: 0 0 112px;
|
|
||||||
width: 112px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
|
|
||||||
|
|
||||||
.card-cover-wrap {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
|
|
||||||
}
|
|
||||||
:global(.card-cover) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
transition: filter 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
.card-gradient {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.card-footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
|
||||||
}
|
|
||||||
.card-badge {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 9px;
|
|
||||||
color: rgba(255,255,255,0.45);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
margin-top: 1px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-msg {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
padding: var(--sp-1) 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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<string, { count: number; original: string }>();
|
||||||
|
|
||||||
|
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<Manga[]> {
|
||||||
|
const filter = {
|
||||||
|
and: [
|
||||||
|
buildTagFilter([genre], "OR", []),
|
||||||
|
{ inLibrary: { equalTo: false } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
Array.from({ length: MAX_PAGES }, (_, i) =>
|
||||||
|
gql<Result>(MANGAS_BY_GENRE, { filter, first: PAGE_SIZE, offset: i * PAGE_SIZE }, signal)
|
||||||
|
.then(d => d.mangas.nodes)
|
||||||
|
.catch(() => [] as Manga[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
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<RecommendedManga[]> {
|
||||||
|
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<number>();
|
||||||
|
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())
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -66,14 +66,14 @@
|
|||||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||||
|
|
||||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||||
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
{@const bOrd = rtl ? sliderMax - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||||
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
|
{@const bPct = sliderMax > 1 ? ((bOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each activeChapterMarkers as m (m.id)}
|
{#each activeChapterMarkers as m (m.id)}
|
||||||
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
|
{@const mOrd = rtl ? sliderMax - m.pageNumber + 1 : m.pageNumber}
|
||||||
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
|
{@const mPct = sliderMax > 1 ? ((mOrd - 1) / (sliderMax - 1)) * 100 : 0}
|
||||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ringFull) return;
|
if (!ringFull) {
|
||||||
|
exitLock = false;
|
||||||
|
exiting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
cancelAnimationFrame(animFrame);
|
cancelAnimationFrame(animFrame);
|
||||||
ringProg = 1;
|
ringProg = 1;
|
||||||
if (lockEnabled && !pinUnlocked) {
|
if (lockEnabled && !pinUnlocked) {
|
||||||
@@ -163,8 +167,6 @@
|
|||||||
return () => clearInterval(dotsInterval);
|
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 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 CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
||||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: 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;
|
const BUF = 80, COLS = 14;
|
||||||
|
|
||||||
// Deterministic per-index hash — no random(), same layout every mount
|
|
||||||
function hash(n: number): number {
|
function hash(n: number): number {
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
||||||
@@ -275,7 +276,6 @@
|
|||||||
for (let i = 0; i < cards.length; i++) {
|
for (let i = 0; i < cards.length; i++) {
|
||||||
const c = cards[i];
|
const c = cards[i];
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
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;
|
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;
|
if (alpha < 0.005) continue;
|
||||||
const cy = c.yStart - p * c.travel;
|
const cy = c.yStart - p * c.travel;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import { probeServer, loginBasic } from "@core/auth";
|
import { probeServer, loginBasic } from "@core/auth";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 10;
|
const MAX_ATTEMPTS = 40;
|
||||||
|
|
||||||
export const boot = $state({
|
export const boot = $state({
|
||||||
serverProbeOk: false,
|
serverProbeOk: false,
|
||||||
@@ -15,20 +15,20 @@ export const boot = $state({
|
|||||||
loginBusy: false,
|
loginBusy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let cancelProbe = false;
|
let probeGeneration = 0;
|
||||||
|
|
||||||
export function startProbe() {
|
export function startProbe() {
|
||||||
cancelProbe = false;
|
const gen = ++probeGeneration;
|
||||||
boot.failed = false;
|
boot.failed = false;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.unsupportedMode = false;
|
boot.unsupportedMode = false;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
|
|
||||||
async function probe() {
|
async function probe() {
|
||||||
if (cancelProbe) return;
|
if (gen !== probeGeneration) return;
|
||||||
tries++;
|
tries++;
|
||||||
const result = await probeServer();
|
const result = await probeServer();
|
||||||
if (cancelProbe) return;
|
if (gen !== probeGeneration) return;
|
||||||
|
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
boot.serverProbeOk = true;
|
boot.serverProbeOk = true;
|
||||||
@@ -59,14 +59,15 @@ export function startProbe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tries >= MAX_ATTEMPTS) { boot.failed = true; return; }
|
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() {
|
export function stopProbe() {
|
||||||
cancelProbe = true;
|
probeGeneration++;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitLogin(onSuccess: () => void) {
|
export async function submitLogin(onSuccess: () => void) {
|
||||||
@@ -99,7 +100,7 @@ export function retryBoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function bypassBoot(onReady: () => void) {
|
export function bypassBoot(onReady: () => void) {
|
||||||
cancelProbe = true;
|
probeGeneration++;
|
||||||
boot.serverProbeOk = true;
|
boot.serverProbeOk = true;
|
||||||
boot.loginRequired = false;
|
boot.loginRequired = false;
|
||||||
boot.unsupportedMode = false;
|
boot.unsupportedMode = false;
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ class Store {
|
|||||||
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
markers: MarkerEntry[] = $state(saved?.markers ?? []);
|
||||||
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
|
||||||
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
readingStats: ReadingStats = $state(saved?.readingStats ?? { ...DEFAULT_READING_STATS });
|
||||||
|
dailyReadCounts: Record<string, number> = $state(saved?.dailyReadCounts ?? {});
|
||||||
searchCache: Map<string, any> = $state(new Map());
|
searchCache: Map<string, any> = $state(new Map());
|
||||||
searchLibraryIds: Set<number> = $state(new Set());
|
searchLibraryIds: Set<number> = $state(new Set());
|
||||||
searchSrcOffset: number = $state(0);
|
searchSrcOffset: number = $state(0);
|
||||||
@@ -250,6 +251,7 @@ class Store {
|
|||||||
settings: this.settings, history: this.history,
|
settings: this.settings, history: this.history,
|
||||||
bookmarks: this.bookmarks, markers: this.markers,
|
bookmarks: this.bookmarks, markers: this.markers,
|
||||||
readLog: this.readLog, readingStats: this.readingStats,
|
readLog: this.readLog, readingStats: this.readingStats,
|
||||||
|
dailyReadCounts: this.dailyReadCounts,
|
||||||
libraryUpdates: this.libraryUpdates,
|
libraryUpdates: this.libraryUpdates,
|
||||||
lastLibraryRefresh: this.lastLibraryRefresh,
|
lastLibraryRefresh: this.lastLibraryRefresh,
|
||||||
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
acknowledgedUpdateIds: [...this.acknowledgedUpdates],
|
||||||
@@ -288,6 +290,8 @@ class Store {
|
|||||||
lastReadAt: entry.readAt, currentStreakDays: streak,
|
lastReadAt: entry.readAt, currentStreakDays: streak,
|
||||||
longestStreakDays: Math.max(this.readingStats.longestStreakDays, streak), lastStreakDate: todayStr,
|
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); }
|
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId); }
|
||||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId); }
|
||||||
clearMarkersForManga(mangaId: number) { this.markers = 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) {
|
clearHistoryForManga(mangaId: number) {
|
||||||
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
this.history = this.history.filter(x => x.mangaId !== mangaId);
|
||||||
@@ -329,6 +333,7 @@ class Store {
|
|||||||
|
|
||||||
wipeAllData() {
|
wipeAllData() {
|
||||||
this.history = []; this.readLog = []; this.markers = [];
|
this.history = []; this.readLog = []; this.markers = [];
|
||||||
|
this.dailyReadCounts = {};
|
||||||
this.readingStats = { ...DEFAULT_READING_STATS };
|
this.readingStats = { ...DEFAULT_READING_STATS };
|
||||||
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user