memo: 한 입력의 여러 주제를 항목별로 분리 저장

- MemoService.organize() → List<MemoDraft> 반환. 프롬프트를 JSON 배열 출력으로
  변경, 서로 다른 주제는 분리·관련 내용은 묶도록 지시. extractJson 배열 대응.
- POST /api/notes/organize → { items: [...] } (save=true면 전부 저장).
- 프론트: 정리 결과를 다중 카드로 표시, 항목별 수정·삭제 + '모두 저장'(카드마다 /memo).

실측: 두서없는 5주제 입력 → 5개 항목(각 PARA 카테고리+GTD 태그)으로 정확히 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 06:41:58 +00:00
parent df9c6a6b50
commit b7588fa3b6
3 changed files with 222 additions and 128 deletions

View File

@@ -6,6 +6,16 @@ import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
// 정리 결과 카드 1개. 한 입력이 여러 항목으로 분리될 수 있다. tags는 편집 편의상 쉼표 문자열로 보관.
type Draft = {
category: string;
title: string;
summary: string;
content: string;
tags: string;
project: string;
};
export default function NewNotePageWrapper() {
return (
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
@@ -28,14 +38,9 @@ function NewNotePage() {
const [transcription, setTranscription] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
// AI 정리(PARA+GTD) 상태
// AI 정리(PARA+GTD) 상태 — 한 입력이 여러 항목으로 분리될 수 있어 배열로 관리
const [organizing, setOrganizing] = useState(false);
const [hasDraft, setHasDraft] = useState(false);
const [category, setCategory] = useState("");
const [project, setProject] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [summary, setSummary] = useState("");
const [organizedContent, setOrganizedContent] = useState("");
const [drafts, setDrafts] = useState<Draft[]>([]);
const handleSaveText = async () => {
if (!title.trim() && !content.trim()) return;
@@ -55,21 +60,26 @@ function NewNotePage() {
if (!content.trim()) return;
setOrganizing(true);
try {
const draft = await request<{
category: string;
title: string;
summary: string;
content: string;
tags: string[];
project: string | null;
const res = await request<{
items: Array<{
category: string;
title: string;
summary: string;
content: string;
tags: string[];
project: string | null;
}>;
}>({ method: "POST", url: "/api/notes/organize", data: { content } });
setTitle(draft.title || "");
setCategory(draft.category || "");
setProject(draft.project || "");
setTagsInput((draft.tags || []).join(", "));
setSummary(draft.summary || "");
setOrganizedContent(draft.content || "");
setHasDraft(true);
setDrafts(
(res.items || []).map((it) => ({
category: it.category || "",
title: it.title || "",
summary: it.summary || "",
content: it.content || "",
tags: (it.tags || []).join(", "),
project: it.project || "",
}))
);
} catch (err) {
console.error("Failed to organize memo:", err);
alert("메모 정리에 실패했습니다.");
@@ -78,22 +88,40 @@ function NewNotePage() {
}
};
const handleSaveMemo = async () => {
if (!category.trim()) return;
const updateDraft = (index: number, field: keyof Draft, value: string) => {
setDrafts((prev) => prev.map((d, i) => (i === index ? { ...d, [field]: value } : d)));
};
const removeDraft = (index: number) => {
setDrafts((prev) => prev.filter((_, i) => i !== index));
};
const handleSaveAll = async () => {
const valid = drafts.filter((d) => d.category.trim());
if (valid.length === 0) return;
setSaving(true);
try {
const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const res = await request<{ id: string }>({
method: "POST",
url: "/api/notes/memo",
data: { category, title, summary, content: organizedContent, tags, project: project || null },
});
router.push(`/notes/${res.id}`);
for (const d of valid) {
const tags = d.tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await request({
method: "POST",
url: "/api/notes/memo",
data: {
category: d.category,
title: d.title,
summary: d.summary,
content: d.content,
tags,
project: d.project || null,
},
});
}
router.push("/notes");
} catch (err) {
console.error("Failed to save memo:", err);
console.error("Failed to save memos:", err);
alert("정리본 저장에 실패했습니다.");
} finally {
setSaving(false);
@@ -225,71 +253,98 @@ function NewNotePage() {
</button>
</div>
{hasDraft && (
{drafts.length > 0 && (
<div className="mt-6 pt-6 border-t border-[var(--color-border)] space-y-4">
<p className="text-sm font-semibold text-[var(--color-primary)]">
·
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(PARA )
</label>
<input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="예: Projects/사이트리뉴얼"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm font-semibold text-[var(--color-primary)]">
{drafts.length} ·
</p>
<button
onClick={handleSaveAll}
disabled={saving || drafts.every((d) => !d.category.trim())}
className="px-5 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40 text-sm"
>
{saving ? "저장 중..." : `모두 저장 (${drafts.length}건)`}
</button>
</div>
{drafts.map((d, i) => (
<div
key={i}
className="rounded-xl border border-[var(--color-border)] p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--color-text-muted)]"> {i + 1}</span>
<button
onClick={() => removeDraft(i)}
className="text-xs text-[var(--color-text-muted)] hover:text-red-400"
>
</button>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<input
value={d.title}
onChange={(e) => updateDraft(i, "title", e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(PARA )
</label>
<input
value={d.category}
onChange={(e) => updateDraft(i, "category", e.target.value)}
placeholder="예: Projects/사이트리뉴얼"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<input
value={d.project}
onChange={(e) => updateDraft(i, "project", e.target.value)}
placeholder="없으면 비워두세요"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
( )
</label>
<input
value={d.tags}
onChange={(e) => updateDraft(i, "tags", e.target.value)}
placeholder="예: 예산안, next-action"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<textarea
value={d.summary}
onChange={(e) => updateDraft(i, "summary", e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(Markdown)
</label>
<textarea
value={d.content}
onChange={(e) => updateDraft(i, "content", e.target.value)}
rows={8}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<input
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="없으면 비워두세요"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
( )
</label>
<input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="예: 예산안, next-action, 회의"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1"></label>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y text-sm"
/>
</div>
<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">
(Markdown)
</label>
<textarea
value={organizedContent}
onChange={(e) => setOrganizedContent(e.target.value)}
rows={12}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y font-mono text-sm"
/>
</div>
<button
onClick={handleSaveMemo}
disabled={saving || !category.trim()}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{saving ? "저장 중..." : "정리본 저장"}
</button>
))}
</div>
)}
</div>