feat(news): 모바일 스플릿뷰 + 책갈피 기능

모바일 풀스크린 오버레이를 제거하고 리스트(35%)+미리보기(65%) 분할뷰로 전환.
pinned 필드를 활용한 책갈피 토글 및 필터 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-14 14:43:04 +09:00
parent 2b5a6d410b
commit c9eeee5fd5
3 changed files with 85 additions and 70 deletions
+1
View File
@@ -79,6 +79,7 @@ class DocumentUpdate(BaseModel):
edit_url: str | None = None
source_channel: str | None = None
data_origin: str | None = None
pinned: bool | None = None
# ─── 스키마 (트리) ───
+3
View File
@@ -116,6 +116,7 @@ async def list_articles(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
unread_only: bool = False,
pinned_only: bool = False,
page: int = 1,
page_size: int = 30,
):
@@ -138,6 +139,8 @@ async def list_articles(
query = query.where(Document.ai_sub_group == source)
if unread_only:
query = query.where(Document.is_read == False)
if pinned_only:
query = query.where(Document.pinned.is_(True))
count_q = select(func.count()).select_from(query.subquery())
total = (await session.execute(count_q)).scalar()
+81 -70
View File
@@ -6,6 +6,7 @@
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { Bookmark, BookmarkCheck } from 'lucide-svelte';
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
@@ -20,6 +21,7 @@
let selectedArticle = $state(null);
let filterSource = $state('');
let showUnreadOnly = $state(false);
let showPinnedOnly = $state(false);
let sourceTree = $state({});
let currentPage = $state(1);
let noteEditing = $state(false);
@@ -28,7 +30,7 @@
let contentText = $state('');
let filterOpen = $state(false);
let expandedSources = $state({});
let savedScrollY = 0;
let previewEl = $state(null);
const PAPER_NAMES = {
'경향신문': '경향신문', '朝日新聞': '朝日新聞', 'NYT': 'NYT',
@@ -62,32 +64,35 @@
params.set('page_size', '30');
if (filterSource) params.set('source', filterSource);
if (showUnreadOnly) params.set('unread_only', 'true');
if (showPinnedOnly) params.set('pinned_only', 'true');
const data = await api(`/news/articles?${params}`);
articles = data.items;
total = data.total;
// 필터 변경 후 selectedArticle 재동기화
if (selectedArticle) {
const match = articles.find(a => a.id === selectedArticle.id);
selectedArticle = match || null;
}
} catch (err) {
addToast('error', '뉴스 로딩 실패');
} finally { loading = false; }
}
function selectArticle(article) {
savedScrollY = document.querySelector('.news-list')?.scrollTop || 0;
selectedArticle = article;
noteEditing = false;
contentEditing = false;
// body scroll lock (모바일)
document.body.style.overflow = 'hidden';
if (!article.is_read) markRead(article);
// 미리보기 스크롤 초기화
requestAnimationFrame(() => {
previewEl?.scrollTo(0, 0);
});
}
function closeArticle() {
selectedArticle = null;
document.body.style.overflow = '';
// 스크롤 복원
requestAnimationFrame(() => {
const list = document.querySelector('.news-list');
if (list) list.scrollTop = savedScrollY;
});
noteEditing = false;
contentEditing = false;
}
async function markRead(article) {
@@ -106,6 +111,25 @@
} catch (e) { addToast('error', '실패'); }
}
async function togglePin(article) {
if (!article) return;
try {
const newPinned = !article.pinned;
await api(`/documents/${article.id}`, {
method: 'PATCH',
body: JSON.stringify({ pinned: newPinned })
});
articles = articles.map(a =>
a.id === article.id ? { ...a, pinned: newPinned } : a
);
if (selectedArticle?.id === article.id) {
selectedArticle = { ...selectedArticle, pinned: newPinned };
}
} catch (e) {
addToast('error', '책갈피 변경 실패');
}
}
async function saveContent() {
try {
const newText = selectedArticle.extracted_text + '\n\n---\n\n' + contentText;
@@ -141,14 +165,14 @@
let prevFilter = '';
$effect(() => {
const key = `${filterSource}|${showUnreadOnly}`;
const key = `${filterSource}|${showUnreadOnly}|${showPinnedOnly}`;
if (key !== prevFilter) { prevFilter = key; currentPage = 1; loadArticles(); }
});
let unreadCount = $derived(articles.filter(a => !a.is_read).length);
</script>
<div class="flex h-full">
<div class="flex h-full overflow-hidden">
<!-- 데스크톱 사이드바 필터 -->
<div class="hidden lg:block w-48 shrink-0 border-r border-[var(--border)] bg-[var(--sidebar-bg)] p-3 overflow-y-auto">
<h2 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-3">필터</h2>
@@ -175,6 +199,10 @@
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
읽지 않음만
</label>
<label class="flex items-center gap-2 px-2 mt-2 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showPinnedOnly} class="rounded">
책갈피만
</label>
</div>
<!-- 메인 -->
@@ -191,6 +219,10 @@
<label class="lg:hidden flex items-center gap-1 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded"> 안읽음
</label>
<!-- 모바일 책갈피 -->
<label class="lg:hidden flex items-center gap-1 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showPinnedOnly} class="rounded"> 책갈피
</label>
<span class="text-xs text-[var(--text-dim)]">{total}건</span>
{#if unreadCount > 0}
<span class="text-[10px] text-[var(--accent)]">● {unreadCount} 안읽음</span>
@@ -213,7 +245,9 @@
{/if}
<!-- 기사 리스트 -->
<div class="news-list flex-1 overflow-y-auto {selectedArticle ? 'hidden lg:block lg:h-[40%] lg:shrink-0 lg:border-b lg:border-[var(--border)]' : ''}">
<div class="news-list overflow-y-auto {selectedArticle
? 'shrink-0 h-[35%] min-h-[180px] max-h-[260px] border-b border-[var(--border)] lg:h-[40%] lg:min-h-0 lg:max-h-none'
: 'flex-1'}">
{#if loading}
<div class="p-4 space-y-2">
{#each Array(5) as _}
@@ -224,22 +258,35 @@
<div class="text-center py-16 text-[var(--text-dim)]"><p class="text-sm">뉴스가 없습니다</p></div>
{:else}
{#each articles as article}
<button onclick={() => selectArticle(article)}
class="w-full text-left px-4 py-3 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] active:bg-[var(--surface)] transition-colors
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={() => selectArticle(article)} role="button" tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectArticle(article); } }}
class="w-full text-left px-3 py-2 lg:px-4 lg:py-3 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] active:bg-[var(--surface)] transition-colors cursor-pointer
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}">
<div class="flex items-start gap-2">
<span class="mt-1.5 text-[10px] shrink-0 {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">{article.is_read ? '○' : '●'}</span>
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
<p class="text-xs text-[var(--text-dim)] mt-1 line-clamp-2">{(article.ai_summary?.replace(/[*#_`~]/g, '') || article.extracted_text?.split('\n').filter(l => l.trim() && l !== article.title)[0] || '').slice(0, 120)}</p>
<p class="text-[13px] lg:text-sm leading-snug {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
<p class="text-xs text-[var(--text-dim)] mt-1 line-clamp-1 lg:line-clamp-2">{(article.ai_summary?.replace(/[*#_`~]/g, '') || article.extracted_text?.split('\n').filter(l => l.trim() && l !== article.title)[0] || '').slice(0, 120)}</p>
<div class="flex items-center gap-2 mt-1 text-[10px] text-[var(--text-dim)]">
<span>{article.ai_sub_group || ''}</span>
{#if article.ai_tags?.length}<span>{article.ai_tags[0]?.split('/').pop()}</span>{/if}
<span>{timeAgo(article.created_at)}</span>
</div>
</div>
<button
onclick={(e) => { e.stopPropagation(); togglePin(article); }}
class="shrink-0 p-2 -m-1 rounded text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors"
aria-label={article.pinned ? '책갈피 해제' : '책갈피 추가'}
>
{#if article.pinned}
<BookmarkCheck size={14} class="text-[var(--accent)]" />
{:else}
<Bookmark size={14} />
{/if}
</button>
</div>
</button>
</div>
{/each}
{/if}
</div>
@@ -254,20 +301,31 @@
</div>
{/if}
<!-- 데스크톱 미리보기 (하단) -->
<!-- 미리보기 패널 (모바일+데스크톱 통합) -->
{#if selectedArticle}
<div class="hidden lg:block lg:flex-1 lg:overflow-y-auto bg-[var(--surface)] min-h-0">
<div class="flex items-center justify-between px-5 py-2 border-b border-[var(--border)] sticky top-0 bg-[var(--surface)]">
<div bind:this={previewEl} class="news-preview flex-1 overflow-y-auto bg-[var(--surface)] min-h-0">
<div class="flex items-center justify-between px-3 py-1.5 lg:px-5 lg:py-2 border-b border-[var(--border)] sticky top-0 bg-[var(--surface)] z-10">
<div class="flex items-center gap-2 text-xs text-[var(--text-dim)]">
<span>{selectedArticle.ai_sub_group}</span><span>·</span><span>{timeAgo(selectedArticle.created_at)}</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => togglePin(selectedArticle)}
class="text-xs flex items-center gap-1 {selectedArticle.pinned ? 'text-[var(--accent)]' : 'text-[var(--text-dim)] hover:text-[var(--accent)]'}"
aria-label={selectedArticle.pinned ? '책갈피 해제' : '책갈피 추가'}
>
{#if selectedArticle.pinned}
<BookmarkCheck size={14} /> 책갈피
{:else}
<Bookmark size={14} /> 책갈피
{/if}
</button>
{#if selectedArticle.edit_url}<a href={selectedArticle.edit_url} target="_blank" rel="noopener noreferrer" class="text-xs text-[var(--accent)]">원문 →</a>{/if}
<button onclick={closeArticle} class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">닫기</button>
</div>
</div>
<div class="p-5">
<h2 class="text-lg font-bold mb-3">{selectedArticle.title}</h2>
<div class="p-3 lg:p-5">
<h2 class="text-base lg:text-lg font-bold mb-2 lg:mb-3">{selectedArticle.title}</h2>
{#if selectedArticle.ai_summary}
<div class="mb-4 p-3 bg-[var(--accent)]/5 border border-[var(--accent)]/20 rounded-lg">
<h4 class="text-[10px] font-semibold text-[var(--accent)] uppercase mb-1">AI 요약</h4>
@@ -297,50 +355,3 @@
{/if}
</div>
</div>
<!-- 모바일 전체화면 미리보기 -->
{#if selectedArticle}
<div class="lg:hidden fixed inset-0 z-30 bg-[var(--bg)] overflow-y-auto">
<div class="sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b border-[var(--border)] bg-[var(--surface)]">
<button onclick={closeArticle} class="text-sm text-[var(--accent)]">← 목록</button>
{#if selectedArticle.edit_url}<a href={selectedArticle.edit_url} target="_blank" rel="noopener noreferrer" class="text-sm text-[var(--accent)]">원문 →</a>{/if}
</div>
<div class="p-4 pb-20">
<h2 class="text-lg font-bold mb-2">{selectedArticle.title}</h2>
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
<span>{selectedArticle.ai_sub_group}</span><span>·</span><span>{timeAgo(selectedArticle.created_at)}</span>
</div>
{#if selectedArticle.ai_summary}
<div class="mb-4 p-3 bg-[var(--accent)]/5 border border-[var(--accent)]/20 rounded-lg">
<h4 class="text-[10px] font-semibold text-[var(--accent)] uppercase mb-1">AI 요약</h4>
<div class="text-sm markdown-body">{@html renderMd(selectedArticle.ai_summary)}</div>
</div>
{/if}
<div class="markdown-body mb-4">{@html renderMd(selectedArticle.extracted_text || '')}</div>
<div class="border-t border-[var(--border)] pt-4 mt-4">
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">본문 입력</h4>
{#if contentEditing}
<textarea bind:value={contentText} class="w-full h-32 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-y outline-none" placeholder="기사 전문..."></textarea>
<div class="flex gap-2 mt-2"><button onclick={saveContent} class="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => contentEditing = false} class="px-3 py-1.5 text-xs text-[var(--text-dim)]">취소</button></div>
{:else}<button onclick={() => { contentText = ''; contentEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 본문 입력</button>{/if}
</div>
<div class="border-t border-[var(--border)] pt-4 mt-4">
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">메모</h4>
{#if noteEditing}
<textarea bind:value={noteText} class="w-full h-20 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-y outline-none" placeholder="메모..."></textarea>
<div class="flex gap-2 mt-2"><button onclick={saveNote} class="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => noteEditing = false} class="px-3 py-1.5 text-xs text-[var(--text-dim)]">취소</button></div>
{:else}
<button onclick={() => { noteText = selectedArticle.user_note || ''; noteEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 메모 추가</button>
{#if selectedArticle.user_note}<div class="mt-2 p-3 bg-[var(--bg)] rounded-lg text-sm">{selectedArticle.user_note}</div>{/if}
{/if}
</div>
</div>
<!-- 모바일 하단 원문 버튼 -->
{#if selectedArticle.edit_url}
<div class="fixed bottom-0 left-0 right-0 p-3 bg-[var(--surface)] border-t border-[var(--border)] lg:hidden">
<a href={selectedArticle.edit_url} target="_blank" rel="noopener noreferrer"
class="block w-full text-center py-2.5 bg-[var(--accent)] text-white rounded-lg text-sm font-medium">원문 보기 →</a>
</div>
{/if}
</div>
{/if}