feat: 뉴스 페이지 모바일 최적화 — 데스크톱/모바일 공존
데스크톱: 사이드바 필터 + 하단 미리보기 (기존 유지) 모바일: 드롭다운 필터 + 전체화면 미리보기 + 하단 원문 버튼 - body scroll lock (모바일 전체화면 시) - 스크롤 위치 복원 - active 터치 피드백 - 안읽음 건수 표시 - 페이지네이션 10개 제한 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/ui';
|
import { addToast } from '$lib/stores/ui';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
@@ -7,10 +9,8 @@
|
|||||||
|
|
||||||
function renderMd(text) {
|
function renderMd(text) {
|
||||||
return DOMPurify.sanitize(marked(text), {
|
return DOMPurify.sanitize(marked(text), {
|
||||||
USE_PROFILES: { html: true },
|
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'],
|
||||||
FORBID_TAGS: ['style', 'script'],
|
FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
FORBID_ATTR: ['onerror', 'onclick'],
|
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,23 +20,25 @@
|
|||||||
let selectedArticle = $state(null);
|
let selectedArticle = $state(null);
|
||||||
let filterSource = $state('');
|
let filterSource = $state('');
|
||||||
let showUnreadOnly = $state(false);
|
let showUnreadOnly = $state(false);
|
||||||
let sourceTree = $state({}); // { 경향신문: ['문화', '사회'], NYT: ['World'] }
|
let sourceTree = $state({});
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
let noteEditing = $state(false);
|
let noteEditing = $state(false);
|
||||||
let noteText = $state('');
|
let noteText = $state('');
|
||||||
let contentEditing = $state(false);
|
let contentEditing = $state(false);
|
||||||
let contentText = $state('');
|
let contentText = $state('');
|
||||||
|
let filterOpen = $state(false);
|
||||||
let expandedSources = $state({});
|
let expandedSources = $state({});
|
||||||
|
let savedScrollY = 0;
|
||||||
|
|
||||||
|
const PAPER_NAMES = {
|
||||||
|
'경향신문': '경향신문', '朝日新聞': '朝日新聞', 'NYT': 'NYT',
|
||||||
|
'Le Monde': 'Le Monde', 'Der Spiegel': 'Der Spiegel', '新华网': '新华网',
|
||||||
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const srcData = await api('/news/sources');
|
const srcData = await api('/news/sources');
|
||||||
const tree = {};
|
const tree = {};
|
||||||
// 신문사 매핑: 이름에서 마지막 단어를 분야로 분리
|
|
||||||
const PAPER_NAMES = {
|
|
||||||
'경향신문': '경향신문', '朝日新聞': '朝日新聞', 'NYT': 'NYT',
|
|
||||||
'Le Monde': 'Le Monde', 'Der Spiegel': 'Der Spiegel', '新华网': '新华网',
|
|
||||||
};
|
|
||||||
srcData.forEach(s => {
|
srcData.forEach(s => {
|
||||||
let paper = '';
|
let paper = '';
|
||||||
for (const [key] of Object.entries(PAPER_NAMES)) {
|
for (const [key] of Object.entries(PAPER_NAMES)) {
|
||||||
@@ -60,30 +62,37 @@
|
|||||||
params.set('page_size', '30');
|
params.set('page_size', '30');
|
||||||
if (filterSource) params.set('source', filterSource);
|
if (filterSource) params.set('source', filterSource);
|
||||||
if (showUnreadOnly) params.set('unread_only', 'true');
|
if (showUnreadOnly) params.set('unread_only', 'true');
|
||||||
|
|
||||||
const data = await api(`/news/articles?${params}`);
|
const data = await api(`/news/articles?${params}`);
|
||||||
articles = data.items;
|
articles = data.items;
|
||||||
total = data.total;
|
total = data.total;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast('error', '뉴스 로딩 실패');
|
addToast('error', '뉴스 로딩 실패');
|
||||||
} finally {
|
} finally { loading = false; }
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectArticle(article) {
|
function selectArticle(article) {
|
||||||
|
savedScrollY = document.querySelector('.news-list')?.scrollTop || 0;
|
||||||
selectedArticle = article;
|
selectedArticle = article;
|
||||||
noteEditing = false;
|
noteEditing = false;
|
||||||
contentEditing = false;
|
contentEditing = false;
|
||||||
|
// body scroll lock (모바일)
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
if (!article.is_read) markRead(article);
|
if (!article.is_read) markRead(article);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeArticle() {
|
||||||
|
selectedArticle = null;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
// 스크롤 복원
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const list = document.querySelector('.news-list');
|
||||||
|
if (list) list.scrollTop = savedScrollY;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(article) {
|
async function markRead(article) {
|
||||||
try {
|
try {
|
||||||
await api(`/documents/${article.id}`, {
|
await api(`/documents/${article.id}`, { method: 'PATCH', body: JSON.stringify({ is_read: true }) });
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ is_read: true }),
|
|
||||||
});
|
|
||||||
article.is_read = true;
|
article.is_read = true;
|
||||||
articles = [...articles];
|
articles = [...articles];
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -94,39 +103,26 @@
|
|||||||
const result = await api('/news/mark-all-read', { method: 'POST' });
|
const result = await api('/news/mark-all-read', { method: 'POST' });
|
||||||
addToast('success', `${result.marked}건 읽음 처리`);
|
addToast('success', `${result.marked}건 읽음 처리`);
|
||||||
articles = articles.map(a => ({ ...a, is_read: true }));
|
articles = articles.map(a => ({ ...a, is_read: true }));
|
||||||
} catch (e) {
|
} catch (e) { addToast('error', '실패'); }
|
||||||
addToast('error', '실패');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveContent() {
|
async function saveContent() {
|
||||||
try {
|
try {
|
||||||
// 본문은 extracted_text에 추가 (기존 요약 유지 + 본문 합산)
|
|
||||||
const newText = selectedArticle.extracted_text + '\n\n---\n\n' + contentText;
|
const newText = selectedArticle.extracted_text + '\n\n---\n\n' + contentText;
|
||||||
await api(`/documents/${selectedArticle.id}/content`, {
|
await api(`/documents/${selectedArticle.id}/content`, { method: 'PUT', body: JSON.stringify({ content: newText }) });
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ content: newText }),
|
|
||||||
});
|
|
||||||
selectedArticle.extracted_text = newText;
|
selectedArticle.extracted_text = newText;
|
||||||
contentEditing = false;
|
contentEditing = false;
|
||||||
addToast('success', '본문 저장됨');
|
addToast('success', '본문 저장됨');
|
||||||
} catch (e) {
|
} catch (e) { addToast('error', '저장 실패'); }
|
||||||
addToast('error', '본문 저장 실패');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
try {
|
try {
|
||||||
await api(`/documents/${selectedArticle.id}`, {
|
await api(`/documents/${selectedArticle.id}`, { method: 'PATCH', body: JSON.stringify({ user_note: noteText }) });
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ user_note: noteText }),
|
|
||||||
});
|
|
||||||
selectedArticle.user_note = noteText;
|
selectedArticle.user_note = noteText;
|
||||||
noteEditing = false;
|
noteEditing = false;
|
||||||
addToast('success', '저장됨');
|
addToast('success', '메모 저장됨');
|
||||||
} catch (e) {
|
} catch (e) { addToast('error', '저장 실패'); }
|
||||||
addToast('error', '저장 실패');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
function timeAgo(dateStr) {
|
||||||
@@ -135,56 +131,46 @@
|
|||||||
if (mins < 60) return `${mins}분 전`;
|
if (mins < 60) return `${mins}분 전`;
|
||||||
const hours = Math.floor(mins / 60);
|
const hours = Math.floor(mins / 60);
|
||||||
if (hours < 24) return `${hours}시간 전`;
|
if (hours < 24) return `${hours}시간 전`;
|
||||||
const days = Math.floor(hours / 24);
|
return `${Math.floor(hours / 24)}일 전`;
|
||||||
return `${days}일 전`;
|
}
|
||||||
|
|
||||||
|
function applyFilter(src) {
|
||||||
|
filterSource = src;
|
||||||
|
filterOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 변경 시에만 1페이지 리셋
|
|
||||||
let prevFilter = '';
|
let prevFilter = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = `${filterSource}|${showUnreadOnly}`;
|
const key = `${filterSource}|${showUnreadOnly}`;
|
||||||
if (key !== prevFilter) {
|
if (key !== prevFilter) { prevFilter = key; currentPage = 1; loadArticles(); }
|
||||||
prevFilter = key;
|
|
||||||
currentPage = 1;
|
|
||||||
loadArticles();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let unreadCount = $derived(articles.filter(a => !a.is_read).length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<!-- 좌측 필터 -->
|
<!-- 데스크톱 사이드바 필터 -->
|
||||||
<div class="w-48 shrink-0 border-r border-[var(--border)] bg-[var(--sidebar-bg)] p-3 overflow-y-auto">
|
<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>
|
<h2 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-3">필터</h2>
|
||||||
|
<button onclick={() => applyFilter('')} class="w-full text-left px-2 py-1.5 rounded text-sm mb-1 {filterSource === '' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">📰 전체</button>
|
||||||
<button
|
|
||||||
onclick={() => { filterSource = ''; }}
|
|
||||||
class="w-full text-left px-2 py-1.5 rounded text-sm mb-1 {filterSource === '' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
|
|
||||||
>📰 전체</button>
|
|
||||||
|
|
||||||
{#each Object.entries(sourceTree) as [paper, categories]}
|
{#each Object.entries(sourceTree) as [paper, categories]}
|
||||||
<div class="mb-0.5">
|
<div class="mb-0.5">
|
||||||
<button
|
<button onclick={() => { applyFilter(paper); expandedSources[paper] = !expandedSources[paper]; }}
|
||||||
onclick={() => { filterSource = paper; expandedSources[paper] = !expandedSources[paper]; }}
|
class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center justify-between {filterSource === paper ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">
|
||||||
class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center justify-between
|
|
||||||
{filterSource === paper ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
|
|
||||||
>
|
|
||||||
<span>{paper}</span>
|
<span>{paper}</span>
|
||||||
<span class="text-[10px]">{expandedSources[paper] ? '▼' : '▶'}</span>
|
<span class="text-[10px]">{expandedSources[paper] ? '▼' : '▶'}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if expandedSources[paper] && categories.length > 0}
|
{#if expandedSources[paper] && categories.length > 0}
|
||||||
{#each categories as cat}
|
{#each categories as cat}
|
||||||
<button
|
<button onclick={() => applyFilter(`${paper}/${cat}`)}
|
||||||
onclick={() => { filterSource = `${paper}/${cat}`; }}
|
class="w-full text-left pl-6 pr-2 py-1 rounded text-xs {filterSource === `${paper}/${cat}` ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}">{cat}</button>
|
||||||
class="w-full text-left pl-6 pr-2 py-1 rounded text-xs
|
|
||||||
{filterSource === `${paper}/${cat}` ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
|
|
||||||
>{cat}</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<hr class="my-3 border-[var(--border)]">
|
<hr class="my-3 border-[var(--border)]">
|
||||||
|
|
||||||
<label class="flex items-center gap-2 px-2 text-xs text-[var(--text-dim)]">
|
<label class="flex items-center gap-2 px-2 text-xs text-[var(--text-dim)]">
|
||||||
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
|
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
|
||||||
읽지 않음만
|
읽지 않음만
|
||||||
@@ -194,45 +180,61 @@
|
|||||||
<!-- 메인 -->
|
<!-- 메인 -->
|
||||||
<div class="flex-1 flex flex-col min-h-0">
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
<!-- 상단 바 -->
|
<!-- 상단 바 -->
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border)] shrink-0">
|
||||||
<span class="text-xs text-[var(--text-dim)]">{total}건</span>
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<!-- 모바일 필터 드롭다운 -->
|
||||||
onclick={markAllRead}
|
<button onclick={() => filterOpen = !filterOpen}
|
||||||
class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)] px-2 py-1 rounded border border-[var(--border)]"
|
class="lg:hidden flex items-center gap-1 px-2 py-1 text-xs rounded border border-[var(--border)] text-[var(--text-dim)]">
|
||||||
>전체 읽음</button>
|
📰 {filterSource || '전체'} <span class="text-[10px]">{filterOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
<!-- 모바일 안읽음 -->
|
||||||
|
<label class="lg:hidden flex items-center gap-1 text-xs text-[var(--text-dim)]">
|
||||||
|
<input type="checkbox" bind:checked={showUnreadOnly} 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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button onclick={markAllRead} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)] px-2 py-1 rounded border border-[var(--border)]">전체 읽음</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 상단: 기사 리스트 -->
|
<!-- 모바일 필터 드롭다운 내용 -->
|
||||||
<div class="overflow-y-auto {selectedArticle ? 'h-[40%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
|
{#if filterOpen}
|
||||||
|
<div class="lg:hidden border-b border-[var(--border)] bg-[var(--sidebar-bg)] px-3 py-2 max-h-60 overflow-y-auto">
|
||||||
|
<button onclick={() => applyFilter('')} class="block w-full text-left px-2 py-2 rounded text-sm {filterSource === '' ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">📰 전체</button>
|
||||||
|
{#each Object.entries(sourceTree) as [paper, categories]}
|
||||||
|
<button onclick={() => applyFilter(paper)} class="block w-full text-left px-2 py-2 rounded text-sm {filterSource === paper ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">{paper}</button>
|
||||||
|
{#each categories as cat}
|
||||||
|
<button onclick={() => applyFilter(`${paper}/${cat}`)} class="block w-full text-left pl-6 pr-2 py-1.5 rounded text-xs {filterSource === `${paper}/${cat}` ? 'text-[var(--accent)]' : 'text-[var(--text-dim)]'}">{cat}</button>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/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)]' : ''}">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _}
|
||||||
<div class="h-12 bg-[var(--surface)] rounded animate-pulse"></div>
|
<div class="h-16 bg-[var(--surface)] rounded animate-pulse"></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if articles.length === 0}
|
{:else if articles.length === 0}
|
||||||
<div class="text-center py-16 text-[var(--text-dim)]">
|
<div class="text-center py-16 text-[var(--text-dim)]"><p class="text-sm">뉴스가 없습니다</p></div>
|
||||||
<p class="text-sm">뉴스가 없습니다</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each articles as article}
|
{#each articles as article}
|
||||||
<button
|
<button onclick={() => selectArticle(article)}
|
||||||
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
|
||||||
class="w-full text-left px-4 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] transition-colors
|
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}">
|
||||||
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="mt-1 text-[10px] {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">
|
<span class="mt-1.5 text-[10px] shrink-0 {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">{article.is_read ? '○' : '●'}</span>
|
||||||
{article.is_read ? '○' : '●'}
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm truncate {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
|
<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)] truncate mt-0.5">{(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-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>
|
||||||
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-[var(--text-dim)]">
|
<div class="flex items-center gap-2 mt-1 text-[10px] text-[var(--text-dim)]">
|
||||||
<span>{article.ai_sub_group || ''}</span>
|
<span>{article.ai_sub_group || ''}</span>
|
||||||
{#if article.ai_tags?.length}
|
{#if article.ai_tags?.length}<span>{article.ai_tags[0]?.split('/').pop()}</span>{/if}
|
||||||
<span>{article.ai_tags[0]?.split('/').pop()}</span>
|
|
||||||
{/if}
|
|
||||||
<span>{timeAgo(article.created_at)}</span>
|
<span>{timeAgo(article.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,112 +245,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 페이지네이션 -->
|
<!-- 페이지네이션 -->
|
||||||
{#if total > 30}
|
{#if total > 30 && !selectedArticle}
|
||||||
<div class="flex justify-center gap-1 py-2 border-t border-[var(--border)] shrink-0">
|
<div class="flex justify-center gap-1 py-2 border-t border-[var(--border)] shrink-0">
|
||||||
{#each Array(Math.ceil(total / 30)) as _, i}
|
{#each Array(Math.min(Math.ceil(total / 30), 10)) as _, i}
|
||||||
<button
|
<button onclick={() => { currentPage = i + 1; loadArticles(); }}
|
||||||
onclick={() => { currentPage = i + 1; loadArticles(); }}
|
class="px-2.5 py-1 rounded text-xs {currentPage === i + 1 ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-dim)]'}">{i + 1}</button>
|
||||||
class="px-2 py-0.5 rounded text-xs {currentPage === i + 1 ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-dim)]'}"
|
|
||||||
>{i + 1}</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- 하단: 기사 미리보기 -->
|
<!-- 데스크톱 미리보기 (하단) -->
|
||||||
{#if selectedArticle}
|
{#if selectedArticle}
|
||||||
<div class="flex-1 overflow-y-auto bg-[var(--surface)] min-h-0">
|
<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 class="flex items-center justify-between px-5 py-2 border-b border-[var(--border)] sticky top-0 bg-[var(--surface)]">
|
||||||
<div class="flex items-center gap-2 text-xs text-[var(--text-dim)]">
|
<div class="flex items-center gap-2 text-xs text-[var(--text-dim)]">
|
||||||
<span>{selectedArticle.ai_sub_group}</span>
|
<span>{selectedArticle.ai_sub_group}</span><span>·</span><span>{timeAgo(selectedArticle.created_at)}</span>
|
||||||
<span>·</span>
|
|
||||||
<span>{timeAgo(selectedArticle.created_at)}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{selectedArticle.file_format}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if selectedArticle.edit_url}
|
{#if selectedArticle.edit_url}<a href={selectedArticle.edit_url} target="_blank" rel="noopener noreferrer" class="text-xs text-[var(--accent)]">원문 →</a>{/if}
|
||||||
<a
|
<button onclick={closeArticle} class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">닫기</button>
|
||||||
href={selectedArticle.edit_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs text-[var(--accent)] hover:underline"
|
|
||||||
>원문 보기 →</a>
|
|
||||||
{/if}
|
|
||||||
<a
|
|
||||||
href="/documents/{selectedArticle.id}"
|
|
||||||
class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
|
|
||||||
>문서 상세</a>
|
|
||||||
<button
|
|
||||||
onclick={() => selectedArticle = null}
|
|
||||||
class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
|
|
||||||
>닫기</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 본문 -->
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<h2 class="text-lg font-bold mb-3">{selectedArticle.title}</h2>
|
<h2 class="text-lg font-bold mb-3">{selectedArticle.title}</h2>
|
||||||
|
|
||||||
<!-- AI 요약 -->
|
|
||||||
{#if selectedArticle.ai_summary}
|
{#if selectedArticle.ai_summary}
|
||||||
<div class="mb-4 p-3 bg-[var(--accent)]/5 border border-[var(--accent)]/20 rounded-lg">
|
<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>
|
<h4 class="text-[10px] font-semibold text-[var(--accent)] uppercase mb-1">AI 요약</h4>
|
||||||
<div class="text-sm markdown-body">
|
<div class="text-sm markdown-body">{@html renderMd(selectedArticle.ai_summary)}</div>
|
||||||
{@html renderMd(selectedArticle.ai_summary)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="markdown-body mb-4">{@html renderMd(selectedArticle.extracted_text || '')}</div>
|
||||||
<!-- RSS 원문 -->
|
|
||||||
<div class="markdown-body mb-4">
|
|
||||||
{@html renderMd(selectedArticle.extracted_text || '')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 본문 입력 -->
|
|
||||||
<div class="border-t border-[var(--border)] pt-4 mt-4">
|
<div class="border-t border-[var(--border)] pt-4 mt-4">
|
||||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">본문 입력</h4>
|
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">본문 입력</h4>
|
||||||
{#if contentEditing}
|
{#if contentEditing}
|
||||||
<textarea
|
<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>
|
||||||
bind:value={contentText}
|
<div class="flex gap-2 mt-2"><button onclick={saveContent} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => contentEditing = false} class="px-3 py-1 text-xs text-[var(--text-dim)]">취소</button></div>
|
||||||
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 focus:border-[var(--accent)]"
|
{:else}<button onclick={() => { contentText = ''; contentEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 본문 입력</button>{/if}
|
||||||
placeholder="기사 전문을 붙여넣으세요..."
|
|
||||||
></textarea>
|
|
||||||
<div class="flex gap-2 mt-2">
|
|
||||||
<button onclick={saveContent} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button>
|
|
||||||
<button onclick={() => contentEditing = false} class="px-3 py-1 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>
|
||||||
|
|
||||||
<!-- 메모 -->
|
|
||||||
<div class="border-t border-[var(--border)] pt-4 mt-4">
|
<div class="border-t border-[var(--border)] pt-4 mt-4">
|
||||||
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">메모</h4>
|
<h4 class="text-xs font-semibold text-[var(--text-dim)] mb-2">메모</h4>
|
||||||
{#if noteEditing}
|
{#if noteEditing}
|
||||||
<textarea
|
<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>
|
||||||
bind:value={noteText}
|
<div class="flex gap-2 mt-2"><button onclick={saveNote} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button><button onclick={() => noteEditing = false} class="px-3 py-1 text-xs text-[var(--text-dim)]">취소</button></div>
|
||||||
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 focus:border-[var(--accent)]"
|
|
||||||
placeholder="메모를 입력하세요..."
|
|
||||||
></textarea>
|
|
||||||
<div class="flex gap-2 mt-2">
|
|
||||||
<button onclick={saveNote} class="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded">저장</button>
|
|
||||||
<button onclick={() => noteEditing = false} class="px-3 py-1 text-xs text-[var(--text-dim)]">취소</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button onclick={() => { noteText = selectedArticle.user_note || ''; noteEditing = true; }} class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)]">+ 메모 추가</button>
|
||||||
onclick={() => { noteText = selectedArticle.user_note || ''; noteEditing = true; }}
|
{#if selectedArticle.user_note}<div class="mt-2 p-3 bg-[var(--bg)] rounded-lg text-sm">{selectedArticle.user_note}</div>{/if}
|
||||||
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,3 +297,50 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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}
|
||||||
|
|||||||
Reference in New Issue
Block a user