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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user