Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
880
frontend-v2/src/routes/(app)/reader/+page.svelte
Normal file
880
frontend-v2/src/routes/(app)/reader/+page.svelte
Normal file
@@ -0,0 +1,880 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// ── Types ──
|
||||
interface Feed { name: string; count: number; id?: number; }
|
||||
interface FeedCategory { name: string; feeds: Feed[]; expanded: boolean; id?: number; }
|
||||
interface Article {
|
||||
id: number; title: string; feed: string; timeAgo: string; readingTime: string;
|
||||
content: string; thumbnail?: string; starred: boolean; read: boolean;
|
||||
author?: string; url?: string;
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
let navItems = $state([
|
||||
{ label: 'Today', count: 0, icon: 'calendar' },
|
||||
{ label: 'Starred', count: 0, icon: 'star' },
|
||||
{ label: 'History', count: 0, icon: 'clock' }
|
||||
]);
|
||||
let feedCategories = $state<FeedCategory[]>([]);
|
||||
let articles = $state<Article[]>([]);
|
||||
let activeNav = $state('Today');
|
||||
let activeFeedId = $state<number | null>(null);
|
||||
let selectedArticle = $state<Article | null>(null);
|
||||
let filterMode = $state<'unread' | 'all'>('unread');
|
||||
let sidebarOpen = $state(false);
|
||||
let autoScrollActive = $state(false);
|
||||
let autoScrollSpeed = $state(1.5);
|
||||
let articleListEl: HTMLDivElement;
|
||||
let scrollRAF: number | null = null;
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let totalUnread = $state(0);
|
||||
|
||||
const LIMIT = 50;
|
||||
let feedCounters: Record<string, number> = {};
|
||||
|
||||
// ── Helpers ──
|
||||
function timeAgo(dateStr: string): string {
|
||||
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (mins < 1) return 'now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function extractThumb(html: string): string | null {
|
||||
const match = html?.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
if (!match) return null;
|
||||
return match[1].replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function mapEntry(e: any): Article {
|
||||
return {
|
||||
id: e.id, title: e.title, feed: e.feed?.title || '', url: e.url,
|
||||
timeAgo: timeAgo(e.published_at), readingTime: `${e.reading_time || 1} min`,
|
||||
content: e.content || '', thumbnail: extractThumb(e.content),
|
||||
starred: e.starred || false, read: e.status === 'read',
|
||||
author: e.author || ''
|
||||
};
|
||||
}
|
||||
|
||||
// ── API ──
|
||||
async function api(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/reader${path}`, { credentials: 'include', ...opts });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function loadSidebar() {
|
||||
try {
|
||||
const [cats, feeds, counters] = await Promise.all([
|
||||
api('/categories'), api('/feeds'), api('/feeds/counters')
|
||||
]);
|
||||
feedCounters = counters.unreads || {};
|
||||
totalUnread = Object.values(feedCounters).reduce((s: number, c: any) => s + (c as number), 0);
|
||||
|
||||
feedCategories = cats.map((c: any) => ({
|
||||
id: c.id, name: c.title,
|
||||
expanded: false,
|
||||
feeds: feeds
|
||||
.filter((f: any) => f.category.id === c.id)
|
||||
.map((f: any) => ({ id: f.id, name: f.title, count: feedCounters[String(f.id)] || 0 }))
|
||||
})).filter((c: FeedCategory) => c.feeds.length > 0);
|
||||
|
||||
// Expand first category
|
||||
if (feedCategories.length > 0) feedCategories[0].expanded = true;
|
||||
|
||||
// Update nav counts
|
||||
navItems[0].count = totalUnread;
|
||||
try {
|
||||
const [starred, history] = await Promise.all([
|
||||
api('/entries?starred=true&limit=1'),
|
||||
api('/entries?status=read&limit=1')
|
||||
]);
|
||||
navItems[1].count = starred.total || 0;
|
||||
navItems[2].count = history.total || 0;
|
||||
} catch { /* silent */ }
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadEntries(append = false) {
|
||||
if (loadingMore) return;
|
||||
if (!append) { loading = true; articles = []; hasMore = true; }
|
||||
else loadingMore = true;
|
||||
|
||||
try {
|
||||
let params = `limit=${LIMIT}&direction=desc&order=published_at`;
|
||||
if (!append) params += '&offset=0';
|
||||
else params += `&offset=${articles.length}`;
|
||||
|
||||
if (activeFeedId) {
|
||||
params += `&feed_id=${activeFeedId}`;
|
||||
}
|
||||
|
||||
if (activeNav === 'Today') {
|
||||
params += '&status=unread';
|
||||
} else if (activeNav === 'Starred') {
|
||||
params += '&starred=true';
|
||||
} else if (activeNav === 'History') {
|
||||
params += '&status=read';
|
||||
}
|
||||
|
||||
const data = await api(`/entries?${params}`);
|
||||
const mapped = (data.entries || []).map(mapEntry);
|
||||
|
||||
if (append) {
|
||||
articles = [...articles, ...mapped];
|
||||
} else {
|
||||
articles = mapped;
|
||||
}
|
||||
hasMore = mapped.length === LIMIT;
|
||||
} catch { /* silent */ }
|
||||
finally { loading = false; loadingMore = false; }
|
||||
}
|
||||
|
||||
async function markEntryRead(id: number) {
|
||||
try {
|
||||
await api('/entries', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: [id], status: 'read' })
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function markEntryUnread(id: number) {
|
||||
try {
|
||||
await api('/entries', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: [id], status: 'unread' })
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function toggleStarAPI(id: number) {
|
||||
try {
|
||||
await api(`/entries/${id}/bookmark`, { method: 'PUT' });
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function markAllReadAPI() {
|
||||
const ids = articles.filter(a => !a.read).map(a => a.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await api('/entries', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: ids, status: 'read' })
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Karakeep ──
|
||||
let karakeepIds = $state<Record<number, string>>({});
|
||||
let savingToKarakeep = $state<Set<number>>(new Set());
|
||||
|
||||
async function toggleKarakeep(article: Article, e?: Event) {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
if (savingToKarakeep.has(article.id)) return;
|
||||
const articleUrl = article.url || '';
|
||||
console.log('Karakeep: saving', article.id, articleUrl);
|
||||
if (!articleUrl && !karakeepIds[article.id]) {
|
||||
console.log('Karakeep: no URL, skipping');
|
||||
return;
|
||||
}
|
||||
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
|
||||
try {
|
||||
if (karakeepIds[article.id]) {
|
||||
const res = await fetch('/api/karakeep/delete', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: karakeepIds[article.id] })
|
||||
});
|
||||
console.log('Karakeep delete:', res.status);
|
||||
const next = { ...karakeepIds };
|
||||
delete next[article.id];
|
||||
karakeepIds = next;
|
||||
} else {
|
||||
const res = await fetch('/api/karakeep/save', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: articleUrl })
|
||||
});
|
||||
const text = await res.text();
|
||||
console.log('Karakeep save:', res.status, text);
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (res.ok && data.ok) {
|
||||
karakeepIds = { ...karakeepIds, [article.id]: data.id };
|
||||
console.log('Karakeep: saved as', data.id);
|
||||
}
|
||||
} catch { console.error('Karakeep: bad JSON response'); }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Karakeep error:', err);
|
||||
} finally {
|
||||
const next = new Set(savingToKarakeep);
|
||||
next.delete(article.id);
|
||||
savingToKarakeep = next;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Touch to stop auto-scroll ──
|
||||
function handleScrollInterrupt() {
|
||||
if (autoScrollActive) stopAutoScroll();
|
||||
}
|
||||
|
||||
// ── Event handlers ──
|
||||
function decrementUnread(count = 1) {
|
||||
totalUnread = Math.max(0, totalUnread - count);
|
||||
navItems[0].count = totalUnread;
|
||||
navItems = [...navItems];
|
||||
}
|
||||
|
||||
const filteredArticles = $derived(articles);
|
||||
|
||||
const currentIndex = $derived(
|
||||
selectedArticle ? filteredArticles.findIndex(a => a.id === selectedArticle!.id) : -1
|
||||
);
|
||||
|
||||
function selectArticle(article: Article) {
|
||||
selectedArticle = article;
|
||||
if (!article.read) {
|
||||
article.read = true;
|
||||
articles = [...articles];
|
||||
markEntryRead(article.id);
|
||||
decrementUnread();
|
||||
}
|
||||
}
|
||||
|
||||
function closeArticle() { selectedArticle = null; }
|
||||
|
||||
function toggleStar(article: Article, e?: Event) {
|
||||
e?.stopPropagation();
|
||||
article.starred = !article.starred;
|
||||
articles = [...articles];
|
||||
toggleStarAPI(article.id);
|
||||
}
|
||||
|
||||
function toggleRead(article: Article) {
|
||||
const wasRead = article.read;
|
||||
article.read = !article.read;
|
||||
articles = [...articles];
|
||||
if (article.read) { markEntryRead(article.id); decrementUnread(); }
|
||||
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
const unreadCount = articles.filter(a => !a.read).length;
|
||||
markAllReadAPI();
|
||||
articles = articles.map(a => ({ ...a, read: true }));
|
||||
decrementUnread(unreadCount);
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (currentIndex < filteredArticles.length - 1) selectArticle(filteredArticles[currentIndex + 1]);
|
||||
}
|
||||
function goPrev() {
|
||||
if (currentIndex > 0) selectArticle(filteredArticles[currentIndex - 1]);
|
||||
}
|
||||
|
||||
function toggleCategory(index: number) {
|
||||
feedCategories[index].expanded = !feedCategories[index].expanded;
|
||||
}
|
||||
|
||||
function selectFeed(feedId: number) {
|
||||
activeFeedId = feedId;
|
||||
activeNav = '';
|
||||
sidebarOpen = false;
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
function selectNav(label: string) {
|
||||
activeNav = label;
|
||||
activeFeedId = null;
|
||||
sidebarOpen = false;
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
|
||||
function startAutoScroll() {
|
||||
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
||||
if (!articleListEl) return;
|
||||
autoScrollActive = true;
|
||||
function step() {
|
||||
if (!autoScrollActive || !articleListEl) return;
|
||||
articleListEl.scrollTop += autoScrollSpeed;
|
||||
scrollRAF = requestAnimationFrame(step);
|
||||
}
|
||||
scrollRAF = requestAnimationFrame(step);
|
||||
}
|
||||
function stopAutoScroll() {
|
||||
autoScrollActive = false;
|
||||
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
|
||||
}
|
||||
function toggleAutoScroll() {
|
||||
if (autoScrollActive) stopAutoScroll();
|
||||
else startAutoScroll();
|
||||
}
|
||||
function adjustSpeed(delta: number) {
|
||||
autoScrollSpeed = Math.max(0.5, Math.min(5, +(autoScrollSpeed + delta).toFixed(1)));
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
|
||||
if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
|
||||
if (e.key === 'Escape' && selectedArticle) { closeArticle(); }
|
||||
if (e.key === 's' && selectedArticle) { toggleStar(selectedArticle); }
|
||||
if (e.key === 'm' && selectedArticle) { toggleRead(selectedArticle); }
|
||||
}
|
||||
|
||||
// ── Mark-as-read on scroll (throttled to avoid jitter) ──
|
||||
let pendingReadIds: number[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollCheckTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleListScroll() {
|
||||
// Throttle: only check every 400ms
|
||||
if (scrollCheckTimer) return;
|
||||
scrollCheckTimer = setTimeout(() => {
|
||||
scrollCheckTimer = null;
|
||||
checkScrolledCards();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function checkScrolledCards() {
|
||||
if (!articleListEl) return;
|
||||
|
||||
// Infinite scroll — load more when near bottom
|
||||
const { scrollTop, scrollHeight, clientHeight } = articleListEl;
|
||||
if (hasMore && !loadingMore && scrollHeight - scrollTop - clientHeight < 300) {
|
||||
loadEntries(true);
|
||||
}
|
||||
|
||||
const listTop = articleListEl.getBoundingClientRect().top;
|
||||
const cards = articleListEl.querySelectorAll('[data-entry-id]');
|
||||
let newlyRead = 0;
|
||||
|
||||
cards.forEach(card => {
|
||||
if (card.getBoundingClientRect().bottom < listTop + 20) {
|
||||
const id = Number(card.getAttribute('data-entry-id'));
|
||||
if (!id) return;
|
||||
const article = articles.find(a => a.id === id);
|
||||
if (article && !article.read) {
|
||||
article.read = true;
|
||||
pendingReadIds.push(id);
|
||||
newlyRead++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newlyRead > 0) {
|
||||
articles = [...articles];
|
||||
decrementUnread(newlyRead);
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = setTimeout(flushPendingReads, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPendingReads() {
|
||||
if (!pendingReadIds.length) return;
|
||||
const ids = [...pendingReadIds];
|
||||
pendingReadIds = [];
|
||||
try {
|
||||
await api('/entries', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entry_ids: ids, status: 'read' })
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
onMount(() => {
|
||||
loadSidebar();
|
||||
loadEntries();
|
||||
return () => {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="reader-layout">
|
||||
|
||||
<!-- Left Sidebar -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<aside class="reader-sidebar" class:open={sidebarOpen}>
|
||||
<div class="sidebar-header">
|
||||
<svg class="rss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||||
<span class="sidebar-title">Reader</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={activeNav === item.label}
|
||||
onclick={() => selectNav(item.label)}
|
||||
>
|
||||
<div class="nav-icon">
|
||||
{#if item.icon === 'inbox'}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
{:else if item.icon === 'calendar'}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
{:else if item.icon === 'star'}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="nav-label">{item.label}</span>
|
||||
{#if item.count > 0}
|
||||
<span class="nav-count">{item.count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-separator"></div>
|
||||
|
||||
<div class="feeds-section">
|
||||
<div class="feeds-header">Feeds</div>
|
||||
{#each feedCategories as cat, i}
|
||||
<div class="feed-category">
|
||||
<button class="category-toggle" onclick={() => toggleCategory(i)}>
|
||||
<svg class="expand-icon" class:expanded={cat.expanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="category-name">{cat.name}</span>
|
||||
<span class="category-count">{cat.feeds.reduce((s, f) => s + f.count, 0)}</span>
|
||||
</button>
|
||||
{#if cat.expanded}
|
||||
<div class="feed-list">
|
||||
{#each cat.feeds as feed}
|
||||
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
|
||||
<span class="feed-name">{feed.name}</span>
|
||||
{#if feed.count > 0}
|
||||
<span class="feed-count">{feed.count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Sidebar overlay for mobile -->
|
||||
{#if sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
|
||||
{/if}
|
||||
|
||||
<!-- Middle Panel: Article List -->
|
||||
<div class="reader-list">
|
||||
<div class="list-header">
|
||||
<div class="list-header-top">
|
||||
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="list-view-name">{activeFeedId ? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'Feed' : activeNav} <span class="list-count">{activeNav === 'Today' && !activeFeedId ? totalUnread : filteredArticles.length}</span></div>
|
||||
<div class="list-actions">
|
||||
<button class="btn-icon" title="Refresh">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" title="Mark all read" onclick={markAllRead}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" class:active-icon={autoScrollActive} onclick={toggleAutoScroll} title="Auto-scroll feed">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
{#if autoScrollActive}
|
||||
<div class="speed-control">
|
||||
<button class="speed-btn" onclick={() => adjustSpeed(-0.5)}>-</button>
|
||||
<span class="speed-value">{autoScrollSpeed}x</span>
|
||||
<button class="speed-btn" onclick={() => adjustSpeed(0.5)}>+</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
|
||||
{#each filteredArticles as article (article.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="article-card"
|
||||
class:selected={selectedArticle?.id === article.id}
|
||||
class:is-read={article.read}
|
||||
data-entry-id={article.id}
|
||||
onclick={() => selectArticle(article)}
|
||||
>
|
||||
<!-- Card header: source + time + actions -->
|
||||
<div class="card-header">
|
||||
<div class="card-source">
|
||||
<svg class="card-rss" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||||
<span class="card-feed-name">{article.feed}</span>
|
||||
{#if article.author}
|
||||
<span class="card-author">· {article.author}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<button class="bookmark-btn" class:saved={!!karakeepIds[article.id]} onclick={(e) => toggleKarakeep(article, e)} title={karakeepIds[article.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
|
||||
{#if savingToKarakeep.has(article.id)}
|
||||
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill={karakeepIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="card-time">{article.timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="card-title">{article.title}</div>
|
||||
|
||||
<!-- Banner image -->
|
||||
{#if article.thumbnail}
|
||||
<div class="card-banner" style="background-image:url('{article.thumbnail}')"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Preview text -->
|
||||
<div class="card-preview">{stripHtml(article.content).slice(0, 200)}</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="card-footer">
|
||||
<span class="card-reading-time">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{article.readingTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filteredArticles.length === 0}
|
||||
<div class="list-empty">No articles to show</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reading pane — slide-in overlay -->
|
||||
{#if selectedArticle}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pane-overlay" onclick={closeArticle}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pane-sheet" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="pane-toolbar">
|
||||
<div class="pane-nav">
|
||||
<button class="pane-nav-btn" onclick={closeArticle} title="Close (Esc)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="pane-nav-btn" onclick={goPrev} disabled={currentIndex <= 0} title="Previous (k)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<span class="pane-nav-pos">{currentIndex + 1} / {filteredArticles.length}</span>
|
||||
<button class="pane-nav-btn" onclick={goNext} disabled={currentIndex >= filteredArticles.length - 1} title="Next (j)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pane-actions">
|
||||
<button class="pane-action-btn" class:saved-karakeep={!!karakeepIds[selectedArticle.id]} onclick={() => toggleKarakeep(selectedArticle!)} title={karakeepIds[selectedArticle.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
|
||||
{#if savingToKarakeep.has(selectedArticle.id)}
|
||||
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill={karakeepIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
{#if selectedArticle.url}
|
||||
<a href={selectedArticle.url} target="_blank" rel="noopener" class="pane-action-btn" title="Open original">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane-scroll">
|
||||
<div class="pane-content">
|
||||
<h1 class="pane-title">{selectedArticle.title}</h1>
|
||||
<div class="pane-meta">
|
||||
<span class="pane-source">{selectedArticle.feed}</span>
|
||||
<span class="pane-dot"></span>
|
||||
<span>{selectedArticle.timeAgo}</span>
|
||||
<span class="pane-dot"></span>
|
||||
<span>{selectedArticle.readingTime} read</span>
|
||||
{#if selectedArticle.author}
|
||||
<span class="pane-dot"></span>
|
||||
<span class="pane-author">by {selectedArticle.author}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
{@html selectedArticle.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.reader-layout {
|
||||
display: flex; height: calc(100vh - 56px);
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
|
||||
/* ── Mobile menu ── */
|
||||
.mobile-menu {
|
||||
display: none; width: 30px; height: 30px; border-radius: var(--radius-sm);
|
||||
background: none; border: 1px solid var(--border);
|
||||
align-items: center; justify-content: center; cursor: pointer;
|
||||
flex-shrink: 0; transition: all var(--transition);
|
||||
}
|
||||
.mobile-menu:hover { background: var(--card-hover); }
|
||||
.mobile-menu svg { width: 16px; height: 16px; color: var(--text-3); }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.reader-sidebar {
|
||||
width: 220px; flex-shrink: 0; background: var(--surface);
|
||||
border-right: 1px solid var(--border); display: flex;
|
||||
flex-direction: column; overflow-y: auto; padding: var(--sp-4) 0 var(--sp-3);
|
||||
}
|
||||
.sidebar-header { display: flex; align-items: center; gap: 7px; padding: 0 16px 12px; }
|
||||
.rss-icon { width: 16px; height: 16px; color: var(--accent); }
|
||||
.sidebar-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: var(--sp-0.5); padding: 0 10px; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-2) 10px; border-radius: var(--radius-sm); background: none; border: none;
|
||||
font-size: var(--text-sm); color: var(--text-3); cursor: pointer;
|
||||
transition: all var(--transition); text-align: left; width: 100%; font-family: var(--font);
|
||||
}
|
||||
.nav-item:hover { background: var(--card-hover); color: var(--text-1); }
|
||||
.nav-item.active { background: none; color: var(--accent); font-weight: 600; border-left: 2px solid var(--accent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; padding-left: 8px; }
|
||||
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
.nav-icon svg { width: 15px; height: 15px; }
|
||||
.nav-label { flex: 1; }
|
||||
.nav-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
|
||||
.nav-item.active .nav-count { color: var(--accent); opacity: 0.7; }
|
||||
|
||||
.sidebar-separator { height: 1px; background: var(--border); margin: 10px 16px; }
|
||||
|
||||
.feeds-section { padding: 0 10px; flex: 1; }
|
||||
.feeds-header { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); padding: 4px 10px 6px; }
|
||||
.feed-category { margin-bottom: 1px; }
|
||||
.category-toggle {
|
||||
display: flex; align-items: center; gap: 5px; width: 100%;
|
||||
padding: 5px 10px; background: none; border: none; font-size: var(--text-sm);
|
||||
font-weight: 500; color: var(--text-2); cursor: pointer; border-radius: var(--radius-sm);
|
||||
transition: all var(--transition); font-family: var(--font);
|
||||
}
|
||||
.category-toggle:hover { background: var(--card-hover); }
|
||||
.category-name { flex: 1; text-align: left; }
|
||||
.category-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
|
||||
.expand-icon { width: 11px; height: 11px; transition: transform var(--transition); flex-shrink: 0; color: var(--text-4); }
|
||||
.expand-icon.expanded { transform: rotate(90deg); }
|
||||
|
||||
.feed-list { padding-left: var(--sp-4); }
|
||||
.feed-item {
|
||||
display: flex; align-items: center; justify-content: space-between; width: 100%;
|
||||
padding: 4px 10px; background: none; border: none; font-size: var(--text-sm);
|
||||
color: var(--text-3); cursor: pointer; border-radius: var(--radius-sm);
|
||||
transition: all var(--transition); text-align: left; font-family: var(--font);
|
||||
}
|
||||
.feed-item:hover { background: var(--card-hover); color: var(--text-1); }
|
||||
.feed-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.feed-count { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; margin-left: var(--sp-1.5); }
|
||||
|
||||
.sidebar-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 39; }
|
||||
|
||||
/* ── Feed list panel ── */
|
||||
.reader-list {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; background: var(--canvas);
|
||||
}
|
||||
.list-header { padding: 12px 20px 10px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.list-header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
|
||||
.list-view-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); display: flex; align-items: center; gap: var(--sp-1.5); }
|
||||
.list-count { font-size: var(--text-xs); font-weight: 400; color: var(--text-4); font-family: var(--mono); }
|
||||
.list-actions { display: flex; gap: var(--sp-1); }
|
||||
.btn-icon { width: 30px; height: 30px; border-radius: var(--radius-sm); background: none; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-3); transition: all var(--transition); }
|
||||
.btn-icon:hover { color: var(--text-1); background: var(--card-hover); }
|
||||
.btn-icon.active-icon { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); }
|
||||
.btn-icon svg { width: 14px; height: 14px; }
|
||||
|
||||
.list-filters { display: flex; gap: var(--sp-1); }
|
||||
.filter-btn {
|
||||
padding: 4px 10px 6px; border-radius: 0; background: none;
|
||||
border: none; border-bottom: 2px solid transparent; font-size: var(--text-sm); font-weight: 500;
|
||||
color: var(--text-3); cursor: pointer; transition: all var(--transition); font-family: var(--font);
|
||||
position: relative;
|
||||
}
|
||||
.filter-btn:hover { color: var(--text-2); }
|
||||
.filter-btn.active { color: var(--text-1); font-weight: 600; border-bottom-color: var(--accent); }
|
||||
|
||||
.speed-control { display: flex; align-items: center; gap: var(--sp-0.5); margin-left: var(--sp-1); }
|
||||
.speed-btn {
|
||||
width: 22px; height: 22px; border-radius: var(--radius-xs); background: var(--card);
|
||||
border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 600;
|
||||
color: var(--text-3); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.speed-btn:hover { color: var(--text-1); }
|
||||
.speed-value { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-2); min-width: 24px; text-align: center; }
|
||||
|
||||
/* ── Article cards (feed style) ── */
|
||||
.article-list { flex: 1; overflow-y: auto; padding: var(--sp-2) var(--sp-5) var(--sp-20); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.article-card {
|
||||
padding: 14px 16px; border-radius: var(--radius);
|
||||
background: var(--card); border: none;
|
||||
box-shadow: var(--card-shadow-sm);
|
||||
cursor: pointer; transition: all var(--transition);
|
||||
}
|
||||
.article-card:hover { box-shadow: var(--card-shadow); transform: translateY(-1px); }
|
||||
.article-card.selected { box-shadow: 0 0 0 1.5px var(--accent); }
|
||||
.article-card.is-read { opacity: 0.6; }
|
||||
.article-card.is-read .card-title { color: var(--text-3); }
|
||||
.article-card.is-read .card-preview { color: var(--text-4); }
|
||||
.article-card.is-read:hover { opacity: 0.8; }
|
||||
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; }
|
||||
.card-source { display: flex; align-items: center; gap: var(--sp-1); font-size: var(--text-xs); color: var(--text-3); font-weight: 500; min-width: 0; }
|
||||
.card-rss { width: 12px; height: 12px; color: var(--text-4); flex-shrink: 0; }
|
||||
.card-feed-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-author { color: var(--text-4); font-weight: 400; white-space: nowrap; }
|
||||
.card-header-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.card-time { font-size: var(--text-xs); color: var(--text-4); }
|
||||
.star-btn {
|
||||
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
||||
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
|
||||
transition: all var(--transition); opacity: 0;
|
||||
}
|
||||
.article-card:hover .star-btn { opacity: 1; }
|
||||
.star-btn.starred { opacity: 1; color: #F59E0B; }
|
||||
.star-btn:hover { color: #F59E0B; }
|
||||
.star-btn svg { width: 13px; height: 13px; }
|
||||
|
||||
.bookmark-btn {
|
||||
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
||||
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
|
||||
transition: all var(--transition); opacity: 0.5;
|
||||
}
|
||||
.article-card:hover .bookmark-btn { opacity: 0.8; }
|
||||
.bookmark-btn.saved { opacity: 1; color: #F59E0B; }
|
||||
.bookmark-btn:hover { opacity: 1; color: #F59E0B; }
|
||||
.bookmark-btn svg { width: 13px; height: 13px; }
|
||||
|
||||
.spinning { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
.card-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); line-height: 1.35; margin-bottom: var(--sp-1.5); }
|
||||
.card-banner { width: 100%; height: 160px; border-radius: var(--radius-md); background: var(--card-hover) center/cover no-repeat; margin-bottom: var(--sp-2); }
|
||||
.card-preview { font-size: var(--text-sm); color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: var(--sp-1); }
|
||||
.card-footer { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.card-reading-time { display: flex; align-items: center; gap: 3px; font-size: var(--text-xs); color: var(--text-4); }
|
||||
.card-reading-time svg { width: 11px; height: 11px; }
|
||||
.list-empty { padding: var(--sp-10); text-align: center; color: var(--text-3); font-size: var(--text-sm); }
|
||||
|
||||
/* ── Reading pane (slide-in sheet) ── */
|
||||
.pane-overlay {
|
||||
position: fixed; inset: 0; background: var(--overlay); z-index: 60;
|
||||
display: flex; justify-content: flex-end; animation: paneFadeIn 150ms ease;
|
||||
}
|
||||
@keyframes paneFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.pane-sheet {
|
||||
width: 620px; max-width: 100%; height: 100%; background: var(--card);
|
||||
display: flex; flex-direction: column;
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -6px 0 28px rgba(0,0,0,0.08);
|
||||
animation: paneSlideIn 200ms ease;
|
||||
}
|
||||
@keyframes paneSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.pane-scroll { flex: 1; overflow-y: auto; }
|
||||
|
||||
.pane-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); flex-shrink: 0;
|
||||
}
|
||||
.pane-nav { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.pane-nav-btn {
|
||||
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
|
||||
border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: var(--text-3); transition: all var(--transition);
|
||||
}
|
||||
.pane-nav-btn:hover { color: var(--text-1); background: var(--card-hover); }
|
||||
.pane-nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.pane-nav-btn svg { width: 14px; height: 14px; }
|
||||
.pane-nav-pos { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); min-width: 44px; text-align: center; }
|
||||
|
||||
.pane-actions { display: flex; align-items: center; gap: var(--sp-0.5); }
|
||||
.pane-action-btn {
|
||||
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
|
||||
border: none; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: var(--text-3); transition: all var(--transition); text-decoration: none;
|
||||
}
|
||||
.pane-action-btn:hover { color: var(--text-1); background: var(--card-hover); }
|
||||
.pane-action-btn.active { color: var(--accent); }
|
||||
.pane-action-btn.saved-karakeep { color: #F59E0B; }
|
||||
.pane-action-btn svg { width: 15px; height: 15px; }
|
||||
|
||||
.pane-content { max-width: 640px; margin: 0 auto; padding: var(--sp-8) 36px var(--sp-20); }
|
||||
.pane-title { font-size: var(--text-xl); font-weight: 600; line-height: 1.3; color: var(--text-1); margin: 0 0 var(--sp-3); letter-spacing: -0.01em; }
|
||||
.pane-meta {
|
||||
display: flex; align-items: center; gap: var(--sp-1.5); flex-wrap: wrap;
|
||||
font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-6);
|
||||
padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pane-source { font-weight: 500; color: var(--text-2); }
|
||||
.pane-author { font-style: italic; }
|
||||
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--text-4); }
|
||||
.pane-body { font-size: var(--text-md); line-height: 1.85; color: var(--text-3); }
|
||||
.pane-body :global(p) { margin-bottom: 18px; }
|
||||
.pane-body :global(img) { max-width: 100%; height: auto; border-radius: var(--radius-md); margin: var(--sp-3) 0; }
|
||||
.pane-body :global(a) { color: var(--accent); text-decoration: underline; }
|
||||
.pane-body :global(a:hover) { opacity: 0.8; }
|
||||
.pane-body :global(blockquote) { border-left: 3px solid var(--border); padding-left: var(--sp-4); margin: var(--sp-4) 0; color: var(--text-3); font-style: italic; }
|
||||
.pane-body :global(pre) { background: var(--surface-secondary); padding: 14px; border-radius: var(--radius-md); overflow-x: auto; font-size: var(--text-sm); font-family: var(--mono); margin: var(--sp-4) 0; }
|
||||
.pane-body :global(code) { font-family: var(--mono); font-size: 0.9em; }
|
||||
.pane-body :global(h1), .pane-body :global(h2), .pane-body :global(h3) { color: var(--text-1); margin: 20px 0 10px; }
|
||||
.pane-body :global(ul), .pane-body :global(ol) { padding-left: var(--sp-5); margin-bottom: var(--sp-4); }
|
||||
.pane-body :global(li) { margin-bottom: var(--sp-1.5); }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 1024px) {
|
||||
.reader-sidebar { display: none; }
|
||||
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); }
|
||||
.mobile-menu { display: flex; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.reader-layout { height: calc(100vh - 56px); }
|
||||
.reader-sidebar { display: none; }
|
||||
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); width: 260px; }
|
||||
.mobile-menu { display: flex; }
|
||||
.article-list { padding: var(--sp-1.5) var(--sp-3) var(--sp-20); gap: var(--sp-1.5); }
|
||||
.article-card { padding: 12px 14px; }
|
||||
.card-title { font-size: var(--text-base); }
|
||||
.card-banner { height: 130px; border-radius: var(--radius-sm); }
|
||||
.card-preview { -webkit-line-clamp: 2; }
|
||||
.pane-sheet { width: 100%; }
|
||||
.pane-content { padding: 20px 18px 80px; }
|
||||
.pane-title { font-size: var(--text-lg); }
|
||||
.pane-body { font-size: var(--text-md); line-height: 1.75; }
|
||||
.pane-toolbar { padding: 8px 14px; }
|
||||
.list-header { padding: 10px 14px 8px; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user