feat: Phase 1D+2 — 모바일 대응, 스마트 그룹, 메모, 태그 편집

- 모바일: 카드 클릭 시 detail 페이지로 이동 (뷰어 패널 미표시)
- 스마트 그룹: 사이드바에 최근 7일/법령 알림/이메일 프리셋 필터
- 메모: user_note 컬럼 추가 (migration 004), PATCH API, PreviewPanel 인라인 편집
- 태그 편집: PreviewPanel에서 태그 추가(+)/삭제(×) 기능
- DB 모델 + API 스키마 user_note 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-03 09:27:18 +09:00
parent 47abf40bf1
commit 17d41a8526
6 changed files with 177 additions and 30 deletions

View File

@@ -36,6 +36,7 @@ class DocumentResponse(BaseModel):
ai_sub_group: str | None
ai_tags: list | None
ai_summary: str | None
user_note: str | None
source_channel: str | None
data_origin: str | None
extracted_at: datetime | None
@@ -60,6 +61,7 @@ class DocumentUpdate(BaseModel):
ai_domain: str | None = None
ai_sub_group: str | None = None
ai_tags: list | None = None
user_note: str | None = None
source_channel: str | None = None
data_origin: str | None = None

View File

@@ -44,6 +44,9 @@ class Document(Base):
embed_model_version: Mapped[str | None] = mapped_column(String(50))
embedded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 사용자 메모
user_note: Mapped[str | None] = mapped_column(Text)
# 메타데이터
source_channel: Mapped[str | None] = mapped_column(
Enum("law_monitor", "devonagent", "email", "web_clip",

View File

@@ -24,6 +24,11 @@
}
function handleClick() {
// 모바일에서는 항상 detail 페이지로 이동
if (window.innerWidth < 1024) {
goto(`/documents/${doc.id}`);
return;
}
if (onselect) {
onselect(doc);
} else {

View File

@@ -1,10 +1,79 @@
<script>
import { X, ExternalLink } from 'lucide-svelte';
import { X, ExternalLink, Plus, Save } from 'lucide-svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import FormatIcon from './FormatIcon.svelte';
import TagPill from './TagPill.svelte';
let { doc, onclose } = $props();
// 메모 상태
let noteText = $state('');
let noteEditing = $state(false);
let noteSaving = $state(false);
// 태그 편집
let newTag = $state('');
let tagEditing = $state(false);
// doc 변경 시 메모 초기화
$effect(() => {
if (doc) {
noteText = doc.user_note || '';
noteEditing = false;
tagEditing = false;
newTag = '';
}
});
async function saveNote() {
noteSaving = true;
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ user_note: noteText }),
});
doc.user_note = noteText;
noteEditing = false;
addToast('success', '메모 저장됨');
} catch (err) {
addToast('error', '메모 저장 실패');
} finally {
noteSaving = false;
}
}
async function addTag() {
const tag = newTag.trim();
if (!tag) return;
const updatedTags = [...(doc.ai_tags || []), tag];
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
newTag = '';
addToast('success', '태그 추가됨');
} catch (err) {
addToast('error', '태그 추가 실패');
}
}
async function removeTag(tagToRemove) {
const updatedTags = (doc.ai_tags || []).filter(t => t !== tagToRemove);
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ ai_tags: updatedTags }),
});
doc.ai_tags = updatedTags;
addToast('success', '태그 삭제됨');
} catch (err) {
addToast('error', '태그 삭제 실패');
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric' });
@@ -25,40 +94,83 @@
<FormatIcon format={doc.file_format} size={16} />
<span class="text-sm font-medium truncate">{doc.title || '제목 없음'}</span>
</div>
<button onclick={onclose} class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" aria-label="닫기">
<X size={16} />
</button>
<div class="flex items-center gap-1">
<a href="/documents/{doc.id}" class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" title="전체 보기">
<ExternalLink size={14} />
</a>
<button onclick={onclose} class="p-1 rounded hover:bg-[var(--surface)] text-[var(--text-dim)]" aria-label="닫기">
<X size={16} />
</button>
</div>
</div>
<div class="flex-1 p-4 space-y-4">
<!-- 전체 보기 버튼 -->
<a
href="/documents/{doc.id}"
class="flex items-center justify-center gap-2 w-full px-3 py-2 bg-[var(--accent)] text-white text-sm rounded-lg hover:bg-[var(--accent-hover)] transition-colors"
>
<ExternalLink size={14} />
전체 보기
</a>
<!-- AI 요약 -->
{#if doc.ai_summary}
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">AI 요약</h4>
<p class="text-sm leading-relaxed">{doc.ai_summary}</p>
</div>
{/if}
<!-- 메모 -->
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">메모</h4>
{#if noteEditing}
<textarea
bind:value={noteText}
class="w-full h-24 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm text-[var(--text)] resize-none outline-none focus:border-[var(--accent)]"
placeholder="메모 입력..."
></textarea>
<div class="flex gap-2 mt-1.5">
<button
onclick={saveNote}
disabled={noteSaving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
<Save size={12} /> 저장
</button>
<button
onclick={() => { noteEditing = false; noteText = doc.user_note || ''; }}
class="px-2 py-1 text-xs text-[var(--text-dim)] hover:text-[var(--text)]"
>취소</button>
</div>
{:else}
<button
onclick={() => noteEditing = true}
class="w-full text-left px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm min-h-[40px]
{noteText ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}"
>
{noteText || '메모 추가...'}
</button>
{/if}
</div>
<!-- 태그 -->
{#if doc.ai_tags?.length}
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">태그</h4>
<div class="flex flex-wrap gap-1">
{#each doc.ai_tags as tag}
<TagPill {tag} />
{/each}
</div>
<div>
<h4 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-1.5">태그</h4>
<div class="flex flex-wrap gap-1 mb-2">
{#each doc.ai_tags || [] as tag}
<span class="inline-flex items-center gap-0.5">
<TagPill {tag} clickable={false} />
<button
onclick={() => removeTag(tag)}
class="text-[var(--text-dim)] hover:text-[var(--error)] text-[10px]"
title="삭제"
>×</button>
</span>
{/each}
</div>
{/if}
{#if tagEditing}
<form onsubmit={(e) => { e.preventDefault(); addTag(); }} class="flex gap-1">
<input
bind:value={newTag}
placeholder="태그 입력..."
class="flex-1 px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs text-[var(--text)] outline-none focus:border-[var(--accent)]"
/>
<button type="submit" class="px-2 py-1 text-xs bg-[var(--accent)] text-white rounded">추가</button>
</form>
{:else}
<button
onclick={() => tagEditing = true}
class="flex items-center gap-1 text-xs text-[var(--text-dim)] hover:text-[var(--accent)]"
>
<Plus size={12} /> 태그 추가
</button>
{/if}
</div>
<!-- 메타 정보 -->
<div>

View File

@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { ChevronRight, ChevronDown, FolderOpen, Folder, Inbox } from 'lucide-svelte';
import { ChevronRight, ChevronDown, FolderOpen, Folder, Inbox, Clock, Mail, Scale } from 'lucide-svelte';
let tree = $state([]);
let inboxCount = $state(0);
@@ -182,6 +182,29 @@
{/if}
</nav>
<!-- 스마트 그룹 -->
<div class="px-2 py-2 border-t border-[var(--border)]">
<h3 class="px-3 py-1 text-[10px] font-semibold text-[var(--text-dim)] uppercase tracking-wider">스마트 그룹</h3>
<button
onclick={() => { const d = new Date(); d.setDate(d.getDate() - 7); navigate(null, null); const params = new URLSearchParams($page.url.searchParams); params.delete('domain'); params.delete('sub_group'); params.delete('page'); const qs = params.toString(); goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true }); }}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Clock size={14} /> 최근 7일
</button>
<button
onclick={() => { const params = new URLSearchParams(); params.set('source', 'law_monitor'); goto(`/documents?${params}`, { noScroll: true }); }}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Scale size={14} /> 법령 알림
</button>
<button
onclick={() => { const params = new URLSearchParams(); params.set('source', 'email'); goto(`/documents?${params}`, { noScroll: true }); }}
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-[var(--text-dim)] hover:bg-[var(--surface)] hover:text-[var(--text)]"
>
<Mail size={14} /> 이메일
</button>
</div>
<!-- 하단: Inbox -->
<div class="px-2 py-2 border-t border-[var(--border)]">
<a

View File

@@ -0,0 +1,2 @@
-- 사용자 메모 컬럼 추가
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_note TEXT;