mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Restructure Repository for SvelteKit
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { BookOpen, CircleNotch } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { GET_RECENTLY_UPDATED, GET_CHAPTERS, LIBRARY_UPDATE_STATUS } from "@api/queries";
|
||||
import { cache, CACHE_GROUPS, CACHE_KEYS } from "@core/cache";
|
||||
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
|
||||
import { dayLabel } from "@core/util";
|
||||
import { buildReaderChapterList } from "@features/series/lib/chapterList";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
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"> {
|
||||
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null;
|
||||
}
|
||||
|
||||
interface UpdateGroup {
|
||||
label: string;
|
||||
items: RecentUpdate[];
|
||||
}
|
||||
|
||||
let updates = $state<RecentUpdate[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let openingId = $state<number | null>(null);
|
||||
let updaterRunning = $state(false);
|
||||
let lastUpdatedTs = $state<number | null>(null);
|
||||
let updaterFinishedJobs = $state<number | null>(null);
|
||||
let updaterTotalJobs = $state<number | null>(null);
|
||||
|
||||
let ctrl: AbortController | null = null;
|
||||
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const RECENT_UPDATES_TTL_MS = 60 * 1_000;
|
||||
const UPDATE_STATUS_POLL_MS = 2_000;
|
||||
|
||||
onMount(() => {
|
||||
onRegisterRefresh?.(() => loadUpdates(true));
|
||||
void loadUpdates();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
ctrl?.abort();
|
||||
stopStatusPolling();
|
||||
});
|
||||
|
||||
function fetchedAtMs(item: Pick<RecentUpdate, "fetchedAt">): number {
|
||||
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now();
|
||||
return Number.isFinite(ts) ? ts : Date.now();
|
||||
}
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const grouped: Record<string, RecentUpdate[]> = {};
|
||||
for (const item of updates) {
|
||||
const label = dayLabel(fetchedAtMs(item));
|
||||
if (!grouped[label]) grouped[label] = [];
|
||||
grouped[label].push(item);
|
||||
}
|
||||
return Object.entries(grouped).map(([label, items]) => ({ label, items })) as UpdateGroup[];
|
||||
});
|
||||
|
||||
const lastUpdatedLabel = $derived(
|
||||
lastUpdatedTs
|
||||
? new Date(lastUpdatedTs).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
const updaterProgressLabel = $derived(
|
||||
typeof updaterFinishedJobs === "number" && typeof updaterTotalJobs === "number" && updaterTotalJobs > 0
|
||||
? `${updaterFinishedJobs}/${updaterTotalJobs}`
|
||||
: null
|
||||
);
|
||||
|
||||
function parseServerTimestamp(value: unknown): number | null {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value === "string") {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) return numeric;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyUpdateStatus(statusRes: {
|
||||
libraryUpdateStatus: {
|
||||
jobsInfo: {
|
||||
isRunning: boolean;
|
||||
finishedJobs?: number;
|
||||
totalJobs?: number;
|
||||
};
|
||||
};
|
||||
lastUpdateTimestamp: { timestamp: string | number | null } | null;
|
||||
} | null) {
|
||||
const jobsInfo = statusRes?.libraryUpdateStatus.jobsInfo;
|
||||
updaterRunning = jobsInfo?.isRunning ?? false;
|
||||
updaterFinishedJobs = typeof jobsInfo?.finishedJobs === "number" ? jobsInfo.finishedJobs : null;
|
||||
updaterTotalJobs = typeof jobsInfo?.totalJobs === "number" ? jobsInfo.totalJobs : null;
|
||||
lastUpdatedTs = parseServerTimestamp(statusRes?.lastUpdateTimestamp?.timestamp ?? null);
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (!statusPollTimer) return;
|
||||
clearTimeout(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
|
||||
function scheduleStatusPoll() {
|
||||
if (statusPollTimer) return;
|
||||
|
||||
const tick = async () => {
|
||||
statusPollTimer = null;
|
||||
try {
|
||||
const statusRes = await gql<{
|
||||
libraryUpdateStatus: {
|
||||
jobsInfo: {
|
||||
isRunning: boolean;
|
||||
finishedJobs: number;
|
||||
totalJobs: number;
|
||||
};
|
||||
};
|
||||
lastUpdateTimestamp: { timestamp: string | number | null } | null;
|
||||
}>(LIBRARY_UPDATE_STATUS, {});
|
||||
|
||||
const wasRunning = updaterRunning;
|
||||
applyUpdateStatus(statusRes);
|
||||
|
||||
if (updaterRunning) {
|
||||
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
|
||||
} else if (wasRunning) {
|
||||
void loadUpdates(true);
|
||||
}
|
||||
} catch {
|
||||
if (updaterRunning) {
|
||||
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
|
||||
}
|
||||
|
||||
function mangaStub(item: RecentUpdate): Manga {
|
||||
return {
|
||||
id: item.manga?.id ?? item.mangaId,
|
||||
title: item.manga?.title ?? "Unknown series",
|
||||
thumbnailUrl: item.manga?.thumbnailUrl ?? "",
|
||||
inLibrary: item.manga?.inLibrary ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function chapterLabel(item: RecentUpdate): string {
|
||||
if (item.name?.trim()) return item.name;
|
||||
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`;
|
||||
return "Chapter";
|
||||
}
|
||||
|
||||
async function loadUpdates(force = false) {
|
||||
ctrl?.abort();
|
||||
const nextCtrl = new AbortController();
|
||||
ctrl = nextCtrl;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const key = CACHE_KEYS.RECENT_UPDATES;
|
||||
if (force) cache.clear(key);
|
||||
|
||||
const [updatesRes, statusRes] = await Promise.all([
|
||||
cache.get<{ chapters: { nodes: RecentUpdate[] } }>(
|
||||
key,
|
||||
() => gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal),
|
||||
RECENT_UPDATES_TTL_MS,
|
||||
CACHE_GROUPS.LIBRARY,
|
||||
),
|
||||
gql<{
|
||||
libraryUpdateStatus: {
|
||||
jobsInfo: { isRunning: boolean };
|
||||
};
|
||||
lastUpdateTimestamp: { timestamp: string | number | null } | null;
|
||||
}>(LIBRARY_UPDATE_STATUS, {}, nextCtrl.signal).catch(() => null),
|
||||
]);
|
||||
|
||||
applyUpdateStatus(statusRes);
|
||||
if (updaterRunning) scheduleStatusPoll();
|
||||
else stopStatusPolling();
|
||||
|
||||
if (nextCtrl.signal.aborted) return;
|
||||
|
||||
updates = updatesRes.chapters.nodes
|
||||
.filter(item => item.manga?.inLibrary)
|
||||
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a));
|
||||
} catch (e: any) {
|
||||
if (nextCtrl.signal.aborted) return;
|
||||
error = e?.message ?? "Failed to load updates";
|
||||
updates = [];
|
||||
updaterRunning = false;
|
||||
lastUpdatedTs = null;
|
||||
updaterFinishedJobs = null;
|
||||
updaterTotalJobs = null;
|
||||
stopStatusPolling();
|
||||
} finally {
|
||||
if (!nextCtrl.signal.aborted) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openUpdate(item: RecentUpdate) {
|
||||
if (openingId !== null) return;
|
||||
openingId = item.id;
|
||||
const manga = mangaStub(item);
|
||||
|
||||
try {
|
||||
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: item.mangaId });
|
||||
const raw = [...res.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[item.mangaId]);
|
||||
const target = list.find(ch => ch.id === item.id);
|
||||
|
||||
if (target) {
|
||||
setActiveManga(manga);
|
||||
openReader(target, list);
|
||||
} else {
|
||||
setActiveManga(manga);
|
||||
}
|
||||
} catch {
|
||||
setActiveManga(manga);
|
||||
addToast({ kind: "error", title: "Couldn't open chapter", body: "Opened the series instead." });
|
||||
} finally {
|
||||
openingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root anim-fade-in">
|
||||
<div class="bar-wrap">
|
||||
<div class="status-bar">
|
||||
<div class="status-dot" class:active={loading || updaterRunning}></div>
|
||||
<span class="status-text">
|
||||
{#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress...{#if updaterProgressLabel} ({updaterProgressLabel}){/if}{:else}Up to date{/if}
|
||||
</span>
|
||||
<div class="status-right">
|
||||
{#if !loading && lastUpdatedLabel}
|
||||
<span class="status-detail">Last updated: {lastUpdatedLabel}</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>
|
||||
|
||||
{#if loading && updates.length === 0}
|
||||
<div class="timeline" aria-hidden="true">
|
||||
<section class="day-group">
|
||||
<div class="day-header">
|
||||
<span class="day-label skeleton sk-day-label"></span>
|
||||
<div class="day-rule skeleton sk-day-rule"></div>
|
||||
</div>
|
||||
|
||||
<div class="updates-list">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<div class="update-row skeleton-row">
|
||||
<div class="thumb-skeleton skeleton"></div>
|
||||
|
||||
<div class="info-skeleton">
|
||||
<div class="skeleton sk-title"></div>
|
||||
<div class="skeleton sk-chapter"></div>
|
||||
<div class="skeleton sk-meta"></div>
|
||||
</div>
|
||||
|
||||
<div class="end-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="empty">
|
||||
<div class="empty-icon-wrap">
|
||||
<BookOpen size={22} weight="light" />
|
||||
</div>
|
||||
<p class="empty-text">Couldn't load updates</p>
|
||||
<p class="empty-hint">{error}</p>
|
||||
</div>
|
||||
{:else if updates.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-icon-wrap">
|
||||
<BookOpen size={22} weight="light" />
|
||||
</div>
|
||||
<p class="empty-text">No recent library updates</p>
|
||||
<p class="empty-hint">Run a library update to populate this page.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="timeline">
|
||||
{#each groups as { label, items } (label)}
|
||||
<section class="day-group">
|
||||
<div class="day-header">
|
||||
<span class="day-label">{label}</span>
|
||||
<div class="day-rule"></div>
|
||||
</div>
|
||||
|
||||
<div class="updates-list">
|
||||
{#each items as item (item.id)}
|
||||
<div class="update-row" class:read={item.isRead}>
|
||||
<button class="thumb-btn" onclick={() => setActiveManga(mangaStub(item))} title="View series">
|
||||
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
|
||||
</button>
|
||||
|
||||
<button class="info-btn" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="series-title">{item.manga?.title ?? "Unknown series"}</span>
|
||||
{#if !item.isRead}
|
||||
<span class="pill">Unread</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||
|
||||
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||
<div class="meta-row">
|
||||
<span>Resume p.{item.lastPageRead}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="row-end">
|
||||
{#if openingId === item.id}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<BookOpen size={14} weight="light" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-wrap { padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
|
||||
|
||||
.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); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
.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); }
|
||||
.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; }
|
||||
|
||||
.timeline {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
|
||||
.day-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day-rule {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: var(--border-dim);
|
||||
}
|
||||
|
||||
.updates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
|
||||
.skeleton {
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
|
||||
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
|
||||
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.45s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.update-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
|
||||
}
|
||||
.update-row:has(.info-btn:hover:not(:disabled)),
|
||||
.update-row:has(.thumb-btn:hover) {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.update-row.read { opacity: 0.5; }
|
||||
|
||||
.skeleton-row {
|
||||
min-height: 74px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumb-skeleton {
|
||||
width: 34px;
|
||||
aspect-ratio: 2 / 3;
|
||||
margin: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.info-skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.sk-title { height: 12px; width: clamp(140px, 42%, 340px); }
|
||||
.sk-chapter { height: 10px; width: clamp(100px, 30%, 260px); }
|
||||
.sk-meta { height: 8px; width: clamp(70px, 18%, 180px); }
|
||||
|
||||
.end-skeleton {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin: auto var(--sp-4) auto 0;
|
||||
opacity: 0.85;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sk-day-label {
|
||||
display: block;
|
||||
width: 74px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.sk-day-rule {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thumb-btn {
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--sp-2);
|
||||
background: none;
|
||||
border: none;
|
||||
/* border-right: 1px solid var(--border-dim); */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.thumb-btn:hover { background: none; }
|
||||
|
||||
:global(.thumb) {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
display: block;
|
||||
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: none; }
|
||||
.info-btn:disabled { cursor: default; opacity: 0.8; }
|
||||
|
||||
.update-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.series-title,
|
||||
.chapter-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.series-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent-fg);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-end {
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--text-faint);
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user