memo: 두서없는 메모 → LLM(PARA+GTD) 자동정리·분류 저장
- MemoService(save_memo tool): organize(정리·분류→구조화 JSON) + saveMemo(카테고리 findOrCreate + 노트+태그 적재) 분리 설계. GUI/외부API/향후 MCP 공통 사용. - 분류 체계: PARA(카테고리 경로) + GTD(next-action/waiting/someday/reference 태그) 하이브리드. - NoteController: POST /api/notes/organize (미리보기, save=true면 원샷 저장), POST /api/notes/memo (구조화 필드 저장). - NoteRepository: insertMemo(project 포함), addTag/findTags. - DB: notes.project 컬럼 + note_tags 테이블 (V2 마이그레이션). - 프론트: notes/new 텍스트 모드에 'AI로 정리' → 미리보기(카테고리/제목/태그/ 프로젝트/요약/본문 수정) → 정리본 저장 흐름 추가. - LLM은 OCI GenAI(현재 gpt-5.6-terra) 구조화 JSON 출력 방식(tool-calling 의존 없음). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
db/migration/V2__memo_tags_project.sql
Normal file
15
db/migration/V2__memo_tags_project.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 메모 자동정리·분류(PARA+GTD) 기능용 스키마
|
||||
-- 2026-07-01 적용 (SQLcl로 수동 반영됨)
|
||||
|
||||
-- notes에 프로젝트 컬럼 추가
|
||||
ALTER TABLE notes ADD (project VARCHAR2(200));
|
||||
|
||||
-- 노트 검색 태그 테이블 (GTD 액션 태그: next-action/waiting/someday/reference 포함)
|
||||
CREATE TABLE note_tags (
|
||||
note_id RAW(16) NOT NULL,
|
||||
tag VARCHAR2(100) NOT NULL,
|
||||
CONSTRAINT fk_note_tags_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_note_tags UNIQUE (note_id, tag)
|
||||
);
|
||||
CREATE INDEX idx_note_tags_note ON note_tags(note_id);
|
||||
CREATE INDEX idx_note_tags_tag ON note_tags(tag);
|
||||
@@ -41,12 +41,15 @@ public class NoteController {
|
||||
private final NoteRepository noteRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final com.sundol.service.OciGenAiService genAiService;
|
||||
private final com.sundol.service.MemoService memoService;
|
||||
|
||||
public NoteController(NoteRepository noteRepository, CategoryRepository categoryRepository,
|
||||
com.sundol.service.OciGenAiService genAiService) {
|
||||
com.sundol.service.OciGenAiService genAiService,
|
||||
com.sundol.service.MemoService memoService) {
|
||||
this.noteRepository = noteRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.genAiService = genAiService;
|
||||
this.memoService = memoService;
|
||||
try { Files.createDirectories(AUDIO_DIR); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -83,6 +86,62 @@ public class NoteController {
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 두서없는 메모를 LLM(PARA+GTD)으로 정리·분류한다. save=true면 저장까지 수행한다(API 원샷).
|
||||
*/
|
||||
@PostMapping("/organize")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> organize(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String content = str(body.get("content"));
|
||||
boolean save = Boolean.TRUE.equals(body.get("save"))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(body.get("save")));
|
||||
com.sundol.service.MemoService.MemoDraft draft = memoService.organize(userId, content);
|
||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("category", draft.category());
|
||||
result.put("title", draft.title());
|
||||
result.put("summary", draft.summary());
|
||||
result.put("content", draft.content());
|
||||
result.put("tags", draft.tags());
|
||||
result.put("project", draft.project());
|
||||
if (save) {
|
||||
String id = memoService.saveMemo(userId, draft.category(), draft.title(),
|
||||
draft.summary(), draft.content(), draft.tags(), draft.project());
|
||||
result.put("id", id);
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다(save_memo). GUI가 미리보기를 수정 후 호출하거나 외부에서 직접 호출.
|
||||
*/
|
||||
@PostMapping("/memo")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> saveMemo(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
return Mono.fromCallable(() -> {
|
||||
String category = str(body.get("category"));
|
||||
String title = str(body.get("title"));
|
||||
String summary = str(body.get("summary"));
|
||||
String content = str(body.get("content"));
|
||||
String project = body.get("project") != null ? str(body.get("project")) : null;
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (body.get("tags") instanceof List<?> list) {
|
||||
for (Object o : list) {
|
||||
if (o != null && !o.toString().isBlank()) tags.add(o.toString());
|
||||
}
|
||||
}
|
||||
String id = memoService.saveMemo(userId, category, title, summary, content, tags, project);
|
||||
return ResponseEntity.ok(Map.<String, Object>of("id", id));
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? "" : o.toString();
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||
@AuthenticationPrincipal String userId,
|
||||
|
||||
@@ -39,6 +39,59 @@ public class NoteRepository {
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다. project 컬럼을 포함하며 note_type은 TEXT로 저장한다.
|
||||
*/
|
||||
public String insertMemo(String userId, String title, String content, String categoryId, String project) {
|
||||
if (categoryId != null) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, category_id, project, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, categoryId, project},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR, java.sql.Types.VARCHAR}
|
||||
);
|
||||
} else {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO notes (id, user_id, title, content, note_type, project, created_at, updated_at) " +
|
||||
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, 'TEXT', ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||
new Object[]{userId, title, content, project},
|
||||
new int[]{java.sql.Types.VARCHAR, java.sql.Types.VARCHAR, java.sql.Types.CLOB, java.sql.Types.VARCHAR}
|
||||
);
|
||||
}
|
||||
var result = jdbcTemplate.queryForList(
|
||||
"SELECT RAWTOHEX(id) AS id FROM notes WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||
userId
|
||||
);
|
||||
return (String) result.get(0).get("ID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 노트에 태그를 추가한다. (note_id, tag) 중복이면 무시한다.
|
||||
*/
|
||||
public void addTag(String noteId, String tag) {
|
||||
Integer cnt = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM note_tags WHERE note_id = HEXTORAW(?) AND tag = ?",
|
||||
Integer.class, noteId, tag
|
||||
);
|
||||
if (cnt == null || cnt == 0) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO note_tags (note_id, tag) VALUES (HEXTORAW(?), ?)",
|
||||
noteId, tag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노트의 태그 목록을 조회한다.
|
||||
*/
|
||||
public List<String> findTags(String noteId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT tag FROM note_tags WHERE note_id = HEXTORAW(?) ORDER BY tag",
|
||||
(rs, rowNum) -> rs.getString("tag"),
|
||||
noteId
|
||||
);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> list(String userId, String categoryId) {
|
||||
if (categoryId != null && !categoryId.isBlank()) {
|
||||
return jdbcTemplate.queryForList(
|
||||
|
||||
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
172
sundol-backend/src/main/java/com/sundol/service/MemoService.java
Normal file
@@ -0,0 +1,172 @@
|
||||
package com.sundol.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sundol.repository.CategoryRepository;
|
||||
import com.sundol.repository.NoteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 두서없는 텍스트 메모를 PARA+GTD 기준으로 LLM이 정리·분류하고, "save_memo" tool 형태로 저장한다.
|
||||
* 추출(organize)과 저장(saveMemo)을 분리하여 GUI 미리보기/외부 API/향후 MCP 노출에 공통 사용한다.
|
||||
*/
|
||||
@Service
|
||||
public class MemoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MemoService.class);
|
||||
|
||||
private final OciGenAiService genAiService;
|
||||
private final NoteRepository noteRepository;
|
||||
private final CategoryRepository categoryRepository;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public MemoService(OciGenAiService genAiService, NoteRepository noteRepository,
|
||||
CategoryRepository categoryRepository) {
|
||||
this.genAiService = genAiService;
|
||||
this.noteRepository = noteRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리·분류 결과. save_memo tool의 인자와 1:1 대응한다.
|
||||
*/
|
||||
public record MemoDraft(String category, String title, String summary,
|
||||
String content, List<String> tags, String project) {}
|
||||
|
||||
private static final String ORGANIZE_SYSTEM = """
|
||||
당신은 PARA + GTD 기반 메모 정리·분류 전문가입니다.
|
||||
사용자가 두서없이 적은 메모를 읽고, 아래 JSON 객체 "하나만" 출력하세요. 다른 설명/코드펜스 없이 JSON만.
|
||||
|
||||
## 필드
|
||||
- category: 반드시 "Projects", "Areas", "Resources", "Archives" 중 하나로 시작하는 경로(예: "Projects/사이트리뉴얼").
|
||||
(Projects=마감·목표 있는 진행 과제, Areas=지속 책임영역, Resources=참고자료·관심사, Archives=완료·보관)
|
||||
가능하면 아래 "기존 카테고리" 중 적합한 것을 그대로 재사용하세요.
|
||||
- title: 12자 내외의 짧은 제목.
|
||||
- summary: 핵심 요약. 불릿 2~3줄(Markdown).
|
||||
- content: 구조화·교정된 본문(Markdown). 소제목·불릿으로 정리하되 원문의 정보를 빠짐없이 보존하세요.
|
||||
원문에 없는 내용을 추가하거나 추측하지 마세요. 정보를 임의로 삭제하지 마세요.
|
||||
- tags: 검색용 태그 배열. 실행이 필요한 메모면 GTD 액션 태그(next-action, waiting, someday, reference)를 함께 포함하세요.
|
||||
- project: 특정 프로젝트에 속하면 프로젝트명, 아니면 null.
|
||||
|
||||
## 규칙
|
||||
1. 원문과 같은 언어로 작성하세요.
|
||||
2. category는 위 4개 루트 중 하나로 반드시 시작해야 합니다.
|
||||
3. 반드시 유효한 JSON 하나만 출력하세요.
|
||||
|
||||
## 기존 카테고리
|
||||
%s
|
||||
""";
|
||||
|
||||
/**
|
||||
* 메모를 LLM으로 정리·분류하여 구조화된 초안을 반환한다. (DB 저장 안 함)
|
||||
*/
|
||||
public MemoDraft organize(String userId, String rawText) throws Exception {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
throw new IllegalArgumentException("정리할 메모 내용이 비어 있습니다.");
|
||||
}
|
||||
if (!genAiService.isConfigured()) {
|
||||
throw new IllegalStateException("OCI GenAI is not configured");
|
||||
}
|
||||
|
||||
List<String> existingCats = new ArrayList<>();
|
||||
for (Map<String, Object> c : categoryRepository.findAllByUser(userId)) {
|
||||
Object fp = c.get("FULL_PATH");
|
||||
if (fp != null && !fp.toString().isBlank()) existingCats.add(fp.toString());
|
||||
}
|
||||
String catList = existingCats.isEmpty() ? "(없음 - 새로 만드세요)" : String.join("\n", existingCats);
|
||||
|
||||
String systemMsg = String.format(ORGANIZE_SYSTEM, catList);
|
||||
String llm = genAiService.chat(systemMsg, "아래 메모를 정리·분류하세요:\n\n" + rawText, null);
|
||||
|
||||
String json = extractJson(llm);
|
||||
JsonNode n;
|
||||
try {
|
||||
n = objectMapper.readTree(json);
|
||||
} catch (Exception e) {
|
||||
log.error("메모 정리 JSON 파싱 실패. 원문 응답: {}", llm.substring(0, Math.min(500, llm.length())));
|
||||
throw new IllegalStateException("LLM 응답을 JSON으로 파싱하지 못했습니다: " + e.getMessage());
|
||||
}
|
||||
|
||||
String category = n.path("category").asText("").strip();
|
||||
if (category.isBlank()) {
|
||||
throw new IllegalStateException("LLM이 category를 반환하지 않았습니다.");
|
||||
}
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
JsonNode tagsNode = n.path("tags");
|
||||
if (tagsNode.isArray()) {
|
||||
for (JsonNode t : tagsNode) {
|
||||
String tag = t.asText("").strip();
|
||||
if (!tag.isBlank()) tags.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode projNode = n.path("project");
|
||||
String project = (projNode.isNull() || projNode.isMissingNode()) ? null : projNode.asText(null);
|
||||
if (project != null && project.isBlank()) project = null;
|
||||
|
||||
return new MemoDraft(
|
||||
category,
|
||||
n.path("title").asText("").strip(),
|
||||
n.path("summary").asText("").strip(),
|
||||
n.path("content").asText("").strip(),
|
||||
tags,
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리된 메모를 저장한다(save_memo tool 본체). 카테고리 경로를 확보하고 노트+태그를 적재한다.
|
||||
* @return 생성된 노트 id
|
||||
*/
|
||||
public String saveMemo(String userId, String category, String title, String summary,
|
||||
String content, List<String> tags, String project) {
|
||||
if (category == null || category.isBlank()) {
|
||||
throw new IllegalArgumentException("category는 필수입니다.");
|
||||
}
|
||||
String categoryId = categoryRepository.findOrCreate(userId, category);
|
||||
|
||||
String safeSummary = summary == null ? "" : summary;
|
||||
String safeContent = content == null ? "" : content;
|
||||
String body = "# 요약\n\n" + safeSummary + "\n\n---\n\n# 본문\n\n" + safeContent
|
||||
+ "\n\n---\n\n*🤖 정리·분류: " + genAiService.getDefaultModel() + "*";
|
||||
|
||||
String noteTitle = (title == null || title.isBlank()) ? "제목 없는 메모" : title;
|
||||
String noteId = noteRepository.insertMemo(userId, noteTitle, body, categoryId, project);
|
||||
|
||||
if (tags != null) {
|
||||
for (String tag : tags) {
|
||||
if (tag != null && !tag.isBlank()) {
|
||||
noteRepository.addTag(noteId, tag.strip());
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Memo saved: note={} category={} tags={} project={}", noteId, category, tags, project);
|
||||
return noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 응답에서 JSON 객체 부분만 추출한다. 코드펜스(```)와 앞뒤 잡텍스트를 제거한다.
|
||||
*/
|
||||
private String extractJson(String s) {
|
||||
String t = s.strip();
|
||||
if (t.startsWith("```")) {
|
||||
int firstNl = t.indexOf('\n');
|
||||
if (firstNl >= 0) t = t.substring(firstNl + 1);
|
||||
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
|
||||
t = t.strip();
|
||||
}
|
||||
int a = t.indexOf('{');
|
||||
int b = t.lastIndexOf('}');
|
||||
if (a >= 0 && b > a) {
|
||||
return t.substring(a, b + 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ function NewNotePage() {
|
||||
const [transcription, setTranscription] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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 handleSaveText = async () => {
|
||||
if (!title.trim() && !content.trim()) return;
|
||||
setSaving(true);
|
||||
@@ -42,6 +51,55 @@ function NewNotePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOrganize = async () => {
|
||||
if (!content.trim()) return;
|
||||
setOrganizing(true);
|
||||
try {
|
||||
const draft = await request<{
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error("Failed to organize memo:", err);
|
||||
alert("메모 정리에 실패했습니다.");
|
||||
} finally {
|
||||
setOrganizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMemo = async () => {
|
||||
if (!category.trim()) 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}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save memo:", err);
|
||||
alert("정리본 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAudio = async () => {
|
||||
if (!audioFile) return;
|
||||
setTranscribing(true);
|
||||
@@ -146,18 +204,95 @@ function NewNotePage() {
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="노트 내용을 입력하세요..."
|
||||
rows={15}
|
||||
placeholder="두서없이 적어도 됩니다. 'AI로 정리'를 누르면 카테고리·제목·태그·요약으로 정리해 드립니다..."
|
||||
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 className="mt-4 flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleOrganize}
|
||||
disabled={organizing || !content.trim()}
|
||||
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{organizing ? "정리 중..." : "✨ AI로 정리"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveText}
|
||||
disabled={saving || (!title.trim() && !content.trim())}
|
||||
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
|
||||
className="px-6 py-2 rounded-lg border border-[var(--color-border)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
{saving ? "저장 중..." : "정리 없이 저장"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasDraft && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user