Feat: Reworked ENTIRE Project for Readability

This commit is contained in:
Youwes09
2026-04-20 00:19:22 -05:00
parent 005680394e
commit 4b97f4a6c9
191 changed files with 19210 additions and 15915 deletions
@@ -0,0 +1,194 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
let {
entries,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[];
onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void;
onopenlibrary: () => void;
} = $props();
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-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;
}
.see-all {
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);
}
.see-all:hover { color: var(--accent-fg); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
border: 1px solid transparent;
background: none;
text-align: left;
cursor: pointer;
width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
:global(.row-thumb) {
width: 33px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute;
left: 0; right: 0; top: 0; bottom: -1px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 16px;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
@@ -0,0 +1,194 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3;
libraryManga: Manga[];
loading: boolean;
onpin: (m: Manga) => void;
onclose: () => void;
} = $props();
let search = $state("");
function focusEl(node: HTMLElement) { node.focus(); }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
);
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
z-index: var(--z-settings);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.1s ease both;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal {
width: min(460px, calc(100vw - 48px));
max-height: 68vh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
animation: scaleIn 0.14s ease both;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.search-wrap {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: var(--text-sm);
}
.search-input::placeholder { color: var(--text-faint); }
.list {
flex: 1;
overflow-y: auto;
padding: var(--sp-2);
scrollbar-width: none;
}
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
padding: var(--sp-4) var(--sp-3);
text-align: center;
}
.list-row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: 8px var(--sp-3);
border-radius: var(--radius-md);
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
:global(.row-thumb) {
height: 50px;
width: 35px;
aspect-ratio: 1 / 1.42;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
background: var(--bg-raised);
display: block;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-source {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,581 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
import type { Manga, Chapter } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
interface HeroSlot {
kind: "continue" | "pinned" | "empty";
entry?: HistoryEntry;
manga?: Manga;
slotIndex: number;
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[];
activeIdx: number;
heroThumb: string;
heroTitle: string;
heroManga: Manga | null | undefined;
heroEntry: HistoryEntry | null;
heroMangaId: number | null;
heroChapters: Chapter[];
loadingHeroChapters: boolean;
resuming: boolean;
onresume: () => void;
onopenchapter: (ch: Chapter) => void;
oncyclenext: () => void;
oncycleprev: () => void;
ongotoslot: (i: number) => void;
onopenpicker: (i: 1 | 2 | 3) => void;
onunpin: (i: 1 | 2 | 3) => void;
onviewall: () => void;
} = $props();
const activeSlot = $derived(resolvedSlots[activeIdx]);
const TOTAL_SLOTS = 4;
</script>
<div class="hero-stage">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === "empty"}
aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === "empty"}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? "Read a manga to see it here"
: "Pin a manga or keep reading to fill this slot"}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === "continue"}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => { setGenreFilter(g); setNavPage("explore"); }}
>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}
<p class="hero-desc">{heroManga.description}</p>
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === "continue"}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === "pinned"}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
<ArrowLeft size={12} weight="bold" />
</button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === "pinned"}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
<ArrowRight size={12} weight="bold" />
</button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header">
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === "empty"}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info">
<div class="sk sk-name"></div>
<div class="sk sk-meta"></div>
</div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
class:chapter-row-read={ch.isRead && !isCurrent}
onclick={() => onopenchapter(ch)}
>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.32);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
transition: background-image 0.3s ease;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
border-right: 1px solid rgba(255, 255, 255, 0.07);
background: var(--bg-raised);
padding: 0;
border-top: none;
border-bottom: none;
border-left: none;
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
color: var(--text-faint);
}
.cover-resume-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.38);
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: none;
}
.hero-details {
position: relative;
z-index: 2;
flex: 1;
min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
.hero-title {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
color: #fff;
line-height: var(--leading-tight);
margin: 0;
flex-shrink: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.45);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.hero-progress {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.55);
letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
.hero-desc {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.38);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-shrink: 0;
}
.hero-empty-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: rgba(255, 255, 255, 0.48);
flex-shrink: 0;
}
.hero-empty-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 18px;
border-radius: var(--radius-md);
background: var(--accent-muted);
border: 1px solid var(--accent-dim);
color: var(--accent-fg);
cursor: pointer;
transition: filter var(--t-base);
white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 14px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.48);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
.hero-nav-row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
margin-top: auto;
padding-top: var(--sp-3);
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.hero-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
cursor: pointer;
padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui);
font-size: 10px;
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.hero-chapters {
position: relative;
z-index: 2;
width: clamp(180px, 30%, 232px);
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: var(--sp-4) var(--sp-3);
gap: 1px;
overflow: hidden;
}
.hero-chapters-header {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.35);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.22);
letter-spacing: var(--tracking-wide);
padding: var(--sp-3) 0;
}
.chapter-row {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 7px var(--sp-2);
border-radius: var(--radius-sm);
background: none;
border: none;
text-align: left;
cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255, 255, 255, 0.07); }
.chapter-row-current { background: rgba(255, 255, 255, 0.1) !important; }
.ch-num {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.32);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
}
.ch-read { color: rgba(255, 255, 255, 0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255, 255, 255, 0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0;
transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+312
View File
@@ -0,0 +1,312 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { gql, thumbUrl } from "@api/client";
import { getBlobUrl } from "@core/cache/imageCache";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache";
import { store, openReader, setHeroSlot, setNavPage, setLibraryFilter, clearLibraryUpdates } from "@store/state.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga, Chapter } from "@types";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import HeroStage from "./HeroStage.svelte";
import HeroSlotPicker from "./HeroSlotPicker.svelte";
import ActivityFeed from "./ActivityFeed.svelte";
import UpdatesRow from "./UpdatesRow.svelte";
import StatsGrid from "./StatsGrid.svelte";
let libraryManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
loadLibrary();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function loadLibrary() {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(console.error)
.finally(() => loadingLibrary = false);
}
function resetAndReload() {
cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true;
heroChapters = [];
heroAllChapters = [];
heroChaptersFor = null;
loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
});
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return;
untrack(() => resetAndReload());
});
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
if (out.length >= 10) break;
}
return out;
})());
const TOTAL_SLOTS = 4;
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
let hi = 1;
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i];
if (pinId != null) {
const manga = libraryManga.find(m => m.id === pinId);
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
}
const entry = continueReading[hi++];
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
}
return slots;
})());
let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
);
const heroManga = $derived(
activeSlot?.kind === "pinned" ? activeSlot.manga :
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
function onKey(e: KeyboardEvent) {
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();
}
let heroChapters: Chapter[] = $state([]);
let heroAllChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
const id = heroMangaId;
void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId;
loadingHeroChapters = true;
heroChapters = [];
heroAllChapters = [];
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all;
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx);
heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; }
}
let resuming = $state(false);
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return;
resuming = true;
try {
let all = heroAllChapters;
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);
}
if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
if (target && heroAllChapters.length) { await openChapter(target); return; }
resuming = true;
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, list);
}
} catch { store.activeManga = { 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 raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, list);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
let pickerOpen = $state(false);
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null);
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true; }
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); }
const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats);
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
const lastRefresh = $derived(store.lastLibraryRefresh);
</script>
<div class="root">
<div class="body">
<div class="hero-section">
<HeroStage
{resolvedSlots}
bind:activeIdx
{heroThumb}
{heroTitle}
{heroManga}
{heroEntry}
{heroMangaId}
{heroChapters}
{loadingHeroChapters}
{resuming}
onresume={resumeActive}
onopenchapter={openChapter}
oncyclenext={cycleNext}
oncycleprev={cyclePrev}
ongotoslot={goToSlot}
onopenpicker={openPicker}
onunpin={unpinSlot}
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
/>
</div>
<ActivityFeed
entries={recentHistory}
onresume={resumeEntry}
onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")}
/>
<div class="bottom-row">
<UpdatesRow
updates={libraryUpdates}
{libraryManga}
{lastRefresh}
onopen={(m) => { if (m) store.previewManga = m; }}
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
/>
<div class="bottom-divider"></div>
<StatsGrid {stats} updateCount={libraryUpdates.length} />
</div>
</div>
</div>
{#if pickerOpen && pickerSlotIndex !== null}
<HeroSlotPicker
slotIndex={pickerSlotIndex}
{libraryManga}
loading={loadingLibrary}
onpin={pinManga}
onclose={closePicker}
/>
{/if}
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.hero-section {
padding: var(--sp-3) var(--sp-4) var(--sp-2);
flex-shrink: 0;
}
.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; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,132 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
import { formatReadTime } from "../lib/homeHelpers";
let {
stats,
updateCount,
}: {
stats: {
currentStreakDays: number;
totalChaptersRead: number;
totalMinutesRead: number;
totalMangaRead: number;
longestStreakDays: number;
};
updateCount: number;
} = $props();
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="grid">
<div class="card">
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
<div class="body">
<span class="val">{stats.currentStreakDays}</span>
<span class="label">Day streak</span>
</div>
</div>
<div class="card">
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalChaptersRead}</span>
<span class="label">Chapters read</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
<div class="body">
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
<span class="label">Read time</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalMangaRead}</span>
<span class="label">Series started</span>
</div>
</div>
<div class="card">
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
<div class="body">
<span class="val">{updateCount}</span>
<span class="label">New updates</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.longestStreakDays}d</span>
<span class="label">Best streak</span>
</div>
</div>
</div>
</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;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex;
align-items: center;
gap: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.green { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui);
font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium);
color: var(--text-secondary);
line-height: 1;
}
.label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
}
</style>
@@ -0,0 +1,187 @@
<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>
View File
+35
View File
@@ -0,0 +1,35 @@
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function formatReadTime(mins: number): string {
if (mins < 1) return `${Math.round(mins * 60)}s`;
if (mins < 60) return `${Math.round(mins)}m`;
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
const d = Math.floor(h / 24), rh = h % 24;
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
}
export function timeAgoRefresh(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
e.stopPropagation();
}