diff --git a/app/api/documents.py b/app/api/documents.py index b28e67e..13ff4a9 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -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 # ─── 스키마 (트리) ─── diff --git a/app/api/news.py b/app/api/news.py index 5c122e7..199e249 100644 --- a/app/api/news.py +++ b/app/api/news.py @@ -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() diff --git a/frontend/src/routes/news/+page.svelte b/frontend/src/routes/news/+page.svelte index 2289e77..e7d48ca 100644 --- a/frontend/src/routes/news/+page.svelte +++ b/frontend/src/routes/news/+page.svelte @@ -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); -
+
@@ -191,6 +219,10 @@ + + {total}건 {#if unreadCount > 0} ● {unreadCount} 안읽음 @@ -213,7 +245,9 @@ {/if} -
+
{#if loading}
{#each Array(5) as _} @@ -224,22 +258,35 @@

뉴스가 없습니다

{:else} {#each articles as article} -
- +
{/each} {/if}
@@ -254,20 +301,31 @@
{/if} - + {#if selectedArticle} -