mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Hoist toolbar into Recent, add status bar, dim read chapters, split cover click
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { store, clearHistory, setPreviewManga } from "@store/state.svelte";
|
import { store, setPreviewManga } from "@store/state.svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
@@ -10,6 +10,13 @@
|
|||||||
import type { Manga } from "@types";
|
import type { Manga } from "@types";
|
||||||
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
search: string;
|
||||||
|
confirmClear: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { search, confirmClear }: Props = $props();
|
||||||
|
|
||||||
let libraryManga = $state<Manga[]>([]);
|
let libraryManga = $state<Manga[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -24,9 +31,6 @@
|
|||||||
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
|
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClear = $state(false);
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -90,42 +94,9 @@
|
|||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="heading-group">
|
|
||||||
<ClockCounterClockwise size={13} weight="light" class="heading-icon" />
|
|
||||||
<span class="heading">History</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={11} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search…" bind:value={search} />
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => search = ""}>×</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
<button
|
|
||||||
class="clear-btn"
|
|
||||||
class:confirm={confirmClear}
|
|
||||||
onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history"}
|
|
||||||
>
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.readingStats.totalChaptersRead > 0}
|
{#if store.readingStats.totalChaptersRead > 0}
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card streak">
|
<div class="stat-card streak">
|
||||||
@@ -230,100 +201,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.heading-icon) { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap :global(.search-icon) {
|
|
||||||
position: absolute;
|
|
||||||
left: 8px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px 26px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
width: 148px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.search:focus {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
transition: color var(--t-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.clear-btn {
|
|
||||||
display: flex; align-items: center; gap: 4px;
|
|
||||||
height: 30px; padding: 0 var(--sp-2);
|
|
||||||
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised); color: var(--text-faint);
|
|
||||||
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
|
||||||
letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
|
||||||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
|
||||||
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
|
||||||
|
|
||||||
.clear-label { font-size: var(--text-2xs); }
|
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ArrowsClockwise, CircleNotch, MagnifyingGlass, Trash } from "phosphor-svelte";
|
||||||
|
import { store, clearHistory } from "@store/state.svelte";
|
||||||
import HistoryPanel from "./HistoryPanel.svelte";
|
import HistoryPanel from "./HistoryPanel.svelte";
|
||||||
import UpdatesPanel from "./UpdatesPanel.svelte";
|
import UpdatesPanel from "./UpdatesPanel.svelte";
|
||||||
|
|
||||||
type RecentTab = "updates" | "history";
|
type RecentTab = "updates" | "history";
|
||||||
let tab = $state<RecentTab>("updates");
|
let tab = $state<RecentTab>("updates");
|
||||||
|
|
||||||
|
// History toolbar state
|
||||||
|
let historySearch = $state("");
|
||||||
|
let historyConfirmClear = $state(false);
|
||||||
|
|
||||||
|
function handleHistoryClear() {
|
||||||
|
if (!historyConfirmClear) {
|
||||||
|
historyConfirmClear = true;
|
||||||
|
setTimeout(() => { historyConfirmClear = false; }, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearHistory();
|
||||||
|
historyConfirmClear = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates toolbar state — bound to the child panel
|
||||||
|
let updatesLoading = $state(true);
|
||||||
|
let updatesRefreshFn = $state<(() => Promise<void>) | null>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="heading">Recent</span>
|
<span class="heading">Recent</span>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab" class:active={tab === "updates"} onclick={() => tab = "updates"}>
|
<button class="tab" class:active={tab === "updates"} onclick={() => tab = "updates"}>
|
||||||
Updates
|
Updates
|
||||||
@@ -17,13 +38,52 @@
|
|||||||
Reading history
|
Reading history
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
{#if tab === "updates"}
|
||||||
|
<button class="icon-btn" onclick={() => updatesRefreshFn?.()} disabled={updatesLoading} title="Refresh updates">
|
||||||
|
{#if updatesLoading}
|
||||||
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else}
|
||||||
|
<ArrowsClockwise size={14} weight="bold" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={11} class="search-icon" weight="light" />
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={historySearch}
|
||||||
|
oninput={(e) => historySearch = (e.target as HTMLInputElement).value}
|
||||||
|
/>
|
||||||
|
{#if historySearch}
|
||||||
|
<button class="search-clear" onclick={() => historySearch = ""}>×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if store.history.length > 0}
|
||||||
|
<button
|
||||||
|
class="clear-btn"
|
||||||
|
class:confirm={historyConfirmClear}
|
||||||
|
onclick={handleHistoryClear}
|
||||||
|
title={historyConfirmClear ? "Click again to confirm" : "Clear history"}
|
||||||
|
>
|
||||||
|
<Trash size={12} weight="light" />
|
||||||
|
{#if historyConfirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{#if tab === "updates"}
|
{#if tab === "updates"}
|
||||||
<UpdatesPanel />
|
<UpdatesPanel
|
||||||
|
bind:loading={updatesLoading}
|
||||||
|
onRegisterRefresh={(fn) => updatesRefreshFn = fn}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<HistoryPanel />
|
<HistoryPanel search={historySearch} confirmClear={historyConfirmClear} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,6 +142,83 @@
|
|||||||
.tab:hover { color: var(--text-muted); }
|
.tab:hover { color: var(--text-muted); }
|
||||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
|
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap :global(.search-icon) {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px 26px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
width: 148px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.search::placeholder { color: var(--text-faint); }
|
||||||
|
.search:focus { border-color: var(--border-strong); background: var(--bg-elevated); width: 200px; }
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
height: 28px; padding: 0 var(--sp-2);
|
||||||
|
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-raised); color: var(--text-faint);
|
||||||
|
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide); flex-shrink: 0;
|
||||||
|
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
|
||||||
|
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
|
||||||
|
.clear-label { font-size: var(--text-2xs); }
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { ArrowsClockwise, BookOpen, CircleNotch } from "phosphor-svelte";
|
import { BookOpen, CircleNotch } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_RECENTLY_UPDATED, GET_CHAPTERS } from "@api/queries";
|
import { GET_RECENTLY_UPDATED, GET_CHAPTERS } from "@api/queries";
|
||||||
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
|
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import type { Chapter, Manga } from "@types";
|
import type { Chapter, Manga } from "@types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
onRegisterRefresh?: (fn: () => Promise<void>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { loading = $bindable(true), onRegisterRefresh }: Props = $props();
|
||||||
|
|
||||||
interface RecentUpdate extends Pick<Chapter, "id" | "name" | "chapterNumber" | "sourceOrder" | "isRead" | "lastPageRead" | "mangaId" | "fetchedAt"> {
|
interface RecentUpdate extends Pick<Chapter, "id" | "name" | "chapterNumber" | "sourceOrder" | "isRead" | "lastPageRead" | "mangaId" | "fetchedAt"> {
|
||||||
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null;
|
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null;
|
||||||
}
|
}
|
||||||
@@ -19,13 +26,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let updates = $state<RecentUpdate[]>([]);
|
let updates = $state<RecentUpdate[]>([]);
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let openingId = $state<number | null>(null);
|
let openingId = $state<number | null>(null);
|
||||||
|
|
||||||
let ctrl: AbortController | null = null;
|
let ctrl: AbortController | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
onRegisterRefresh?.(loadUpdates);
|
||||||
void loadUpdates();
|
void loadUpdates();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,22 +55,20 @@
|
|||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items })) as UpdateGroup[];
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items })) as UpdateGroup[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastUpdatedTs = $derived(
|
const lastCheckedTs = $derived(
|
||||||
store.lastLibraryRefresh > 0
|
updates.length > 0 ? fetchedAtMs(updates[0]) : null
|
||||||
? store.lastLibraryRefresh
|
|
||||||
: (updates.length > 0 ? fetchedAtMs(updates[0]) : null)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastUpdatedLabel = $derived(
|
const lastCheckedLabel = $derived(
|
||||||
lastUpdatedTs
|
lastCheckedTs
|
||||||
? new Date(lastUpdatedTs).toLocaleString("en-US", {
|
? new Date(lastCheckedTs).toLocaleString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})
|
})
|
||||||
: "Never"
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
function mangaStub(item: RecentUpdate): Manga {
|
function mangaStub(item: RecentUpdate): Manga {
|
||||||
@@ -131,16 +136,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
<div class="header">
|
<div class="bar-wrap">
|
||||||
<div class="heading-group">
|
<div class="status-bar">
|
||||||
<ArrowsClockwise size={13} weight="light" class="heading-icon" />
|
<div class="status-dot" class:active={loading}></div>
|
||||||
<span class="heading">Library updates</span>
|
<span class="status-text">
|
||||||
<span class="last-updated">Last updated: {lastUpdatedLabel}</span>
|
{#if loading}Checking for updates…{:else if error}Update check failed{:else}Up to date{/if}
|
||||||
|
</span>
|
||||||
|
<div class="status-right">
|
||||||
|
{#if !loading && lastCheckedLabel}
|
||||||
|
<span class="status-detail">Last checked: {lastCheckedLabel}</span>
|
||||||
|
<div class="bar-sep"></div>
|
||||||
|
{/if}
|
||||||
|
{#if !loading && updates.length > 0}
|
||||||
|
<span class="status-count">{updates.length} chapter{updates.length === 1 ? "" : "s"}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" onclick={loadUpdates} disabled={loading} title="Refresh updates">
|
|
||||||
{#if loading}<CircleNotch size={14} weight="light" class="anim-spin" />
|
|
||||||
{:else}<ArrowsClockwise size={14} weight="bold" />{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading && updates.length === 0}
|
{#if loading && updates.length === 0}
|
||||||
@@ -177,11 +188,12 @@
|
|||||||
|
|
||||||
<div class="updates-list">
|
<div class="updates-list">
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<button class="update-row" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
|
<div class="update-row" class:read={item.isRead}>
|
||||||
<div class="thumb-wrap">
|
<button class="thumb-btn" onclick={() => setActiveManga(mangaStub(item))} title="View series">
|
||||||
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
|
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
<button class="info-btn" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
|
||||||
<div class="update-info">
|
<div class="update-info">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="series-title">{item.manga?.title ?? "Unknown series"}</span>
|
<span class="series-title">{item.manga?.title ?? "Unknown series"}</span>
|
||||||
@@ -207,6 +219,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -223,60 +236,21 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.bar-wrap { padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading-group {
|
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
|
||||||
display: flex;
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||||
align-items: center;
|
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||||
gap: var(--sp-2);
|
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
|
||||||
}
|
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||||
|
.status-detail { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
:global(.heading-icon) { color: var(--text-faint); }
|
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-updated {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: var(--text-2xs);
|
|
||||||
color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-faint);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
|
||||||
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-6);
|
padding: var(--sp-4) var(--sp-6) var(--sp-6);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-5);
|
gap: var(--sp-5);
|
||||||
@@ -316,42 +290,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-row {
|
.update-row {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
align-items: stretch;
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-3);
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--sp-2);
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
cursor: pointer;
|
overflow: hidden;
|
||||||
text-align: left;
|
transition: border-color var(--t-base), transform var(--t-base);
|
||||||
transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);
|
|
||||||
}
|
}
|
||||||
.update-row:hover:not(:disabled) {
|
.update-row:has(.info-btn:hover:not(:disabled)) {
|
||||||
background: var(--bg-raised);
|
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.update-row:disabled { cursor: default; opacity: 0.8; }
|
.update-row.read { opacity: 0.5; }
|
||||||
|
|
||||||
.thumb-wrap {
|
.thumb-btn {
|
||||||
width: 52px;
|
width: 52px;
|
||||||
aspect-ratio: 2 / 3;
|
flex-shrink: 0;
|
||||||
border-radius: var(--radius-md);
|
padding: var(--sp-2);
|
||||||
overflow: hidden;
|
background: none;
|
||||||
background: var(--bg-overlay);
|
border: none;
|
||||||
border: 1px solid var(--border-dim);
|
border-right: 1px solid var(--border-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background var(--t-base);
|
||||||
}
|
}
|
||||||
|
.thumb-btn:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
:global(.thumb) {
|
:global(.thumb) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
aspect-ratio: 2 / 3;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
.info-btn:hover:not(:disabled) { background: var(--bg-raised); }
|
||||||
|
.info-btn:disabled { cursor: default; opacity: 0.8; }
|
||||||
|
|
||||||
.update-info {
|
.update-info {
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -409,6 +401,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -447,4 +440,6 @@
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user