Add Notes, Voice Clone TTS, fix auth persistence and maxTokens

Notes:
- notes table with TEXT/AUDIO types, category support
- Audio upload → OpenRouter Gemini STT → OCI GenAI polish/summary
- Raw STT saved separately in raw_content column
- Polish/summary button for manual re-processing
- Async processing with real-time polling

Voice Clone TTS:
- Qwen3-TTS 1.7B model on A10 GPU via FastAPI server
- Voice profile registration (record/upload → save embedding)
- Profile-based TTS generation API
- TTS web page with recording, profile management, generation

Auth fixes:
- Store both access + refresh tokens in localStorage
- Initialize state from localStorage synchronously (no flash)
- Request interceptor reads token from localStorage every request
- Refresh via body (not just cookie)

Other fixes:
- maxTokens 4096 → 65536 (OCI GenAI Gemini supports up to 65536)
- Fix broken Korean chars in source files
- OpenRouter config for STT
- ffmpeg installed for audio conversion
- Ollama + Gemma 4 E4B installed (STT fallback)
- nginx proxy for TTS server (/api/tts/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 07:34:18 +00:00
parent 6c2129d42e
commit 1088b23790
14 changed files with 1863 additions and 120 deletions

View File

@@ -0,0 +1,275 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
import ReactMarkdown from "react-markdown";
interface NoteDetail {
ID: string;
TITLE: string;
CONTENT: string;
RAW_CONTENT: string | null;
NOTE_TYPE: string;
AUDIO_PATH: string | null;
CATEGORY_PATH: string | null;
CREATED_AT: string;
UPDATED_AT: string;
}
export default function NoteDetailPage() {
const { request } = useApi();
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [note, setNote] = useState<NoteDetail | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editContent, setEditContent] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [polishing, setPolishing] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const fetchNote = async () => {
try {
const data = await request<NoteDetail>({ method: "GET", url: `/api/notes/${id}` });
setNote(data);
setEditTitle(data.TITLE || "");
setEditContent(data.CONTENT || "");
} catch (err) {
console.error("Failed to load note:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchNote();
}, [id]);
// TRANSCRIBING 상태면 3초 폴링
useEffect(() => {
if (!note || note.NOTE_TYPE !== "TRANSCRIBING") return;
const interval = setInterval(fetchNote, 3000);
return () => clearInterval(interval);
}, [note?.NOTE_TYPE]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await request<NoteDetail>({
method: "PATCH",
url: `/api/notes/${id}`,
data: { title: editTitle, content: editContent },
});
setNote(updated);
setEditing(false);
} catch (err) {
console.error("Failed to update note:", err);
alert("저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm("정말 삭제하시겠습니까?")) return;
setDeleting(true);
try {
await request({ method: "DELETE", url: `/api/notes/${id}` });
router.push("/notes");
} catch (err) {
console.error("Failed to delete note:", err);
setDeleting(false);
}
};
if (loading) {
return (
<AuthGuard><NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</main>
</AuthGuard>
);
}
if (!note) {
return (
<AuthGuard><NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-red-400"> .</p>
<button onClick={() => router.push("/notes")} className="mt-4 text-sm text-[var(--color-primary)] hover:underline">
&larr; Back to Notes
</button>
</main>
</AuthGuard>
);
}
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<button
onClick={() => router.push("/notes")}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
>
&larr; Back to Notes
</button>
{/* 헤더 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
<div className="flex items-center gap-2 mb-3">
<span className={`text-xs px-2 py-0.5 rounded ${
note.NOTE_TYPE === "TRANSCRIBING" ? "bg-yellow-500/20 text-yellow-400" :
note.NOTE_TYPE === "AUDIO_FAILED" ? "bg-red-500/20 text-red-400" :
note.NOTE_TYPE === "AUDIO" ? "bg-purple-500/20 text-purple-400" : "bg-blue-500/20 text-blue-400"
}`}>
{note.NOTE_TYPE === "TRANSCRIBING" ? "변환 중..." :
note.NOTE_TYPE === "AUDIO_FAILED" ? "변환 실패" :
note.NOTE_TYPE === "AUDIO" ? "음성" : "텍스트"}
</span>
{note.CATEGORY_PATH && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{note.CATEGORY_PATH}
</span>
)}
</div>
{editing ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full px-3 py-1 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-xl font-bold mb-2"
/>
) : (
<h1 className="text-xl font-bold mb-2">{note.TITLE || "제목 없음"}</h1>
)}
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>: {new Date(note.CREATED_AT).toLocaleString("ko-KR")}</span>
<span>: {new Date(note.UPDATED_AT).toLocaleString("ko-KR")}</span>
</div>
</div>
{/* 변환 중 인디케이터 */}
{note.NOTE_TYPE === "TRANSCRIBING" && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<div className="w-4 h-4 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-yellow-400"> . .</span>
</div>
)}
{/* 내용 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
{editing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={20}
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="text-sm leading-relaxed">
<ReactMarkdown
components={{
h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>,
h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>,
h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>,
p: ({children}) => <p className="mb-3">{children}</p>,
ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>,
ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>,
li: ({children}) => <li className="leading-relaxed">{children}</li>,
strong: ({children}) => <strong className="font-bold">{children}</strong>,
blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>,
}}
>
{note.CONTENT || "내용 없음"}
</ReactMarkdown>
</div>
)}
</div>
{/* 원본 텍스트 (STT raw) */}
{note.RAW_CONTENT && (
<div className="mb-6">
<button
onClick={() => setShowRaw(!showRaw)}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-2"
>
{showRaw ? "▼ 원본 텍스트 숨기기" : "▶ STT 원본 텍스트 보기"}
</button>
{showRaw && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] opacity-70">
<p className="text-xs text-[var(--color-text-muted)] mb-2"> ( )</p>
<p className="text-sm whitespace-pre-wrap">{note.RAW_CONTENT}</p>
</div>
)}
</div>
)}
{/* 액션 */}
<div className="flex items-center gap-4">
{editing ? (
<>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm bg-[var(--color-primary)] rounded-lg disabled:opacity-40"
>
{saving ? "저장 중..." : "저장"}
</button>
<button
onClick={() => { setEditing(false); setEditTitle(note.TITLE || ""); setEditContent(note.CONTENT || ""); }}
className="px-4 py-2 text-sm bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg"
>
</button>
</>
) : (
<>
<button
onClick={() => setEditing(true)}
className="text-sm text-[var(--color-primary)] hover:underline"
>
</button>
<button
onClick={async () => {
setPolishing(true);
try {
await request({ method: "POST", url: `/api/notes/${id}/polish` });
await fetchNote();
} catch (err) {
console.error("Failed to polish:", err);
alert("교정/요약에 실패했습니다.");
} finally {
setPolishing(false);
}
}}
disabled={polishing || note.NOTE_TYPE === "TRANSCRIBING"}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{polishing ? "처리 중..." : "교정/요약"}
</button>
</>
)}
<button
onClick={handleDelete}
disabled={deleting}
className="text-sm text-red-400 hover:text-red-300 disabled:opacity-40"
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useState, useRef, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
export default function NewNotePageWrapper() {
return (
<Suspense fallback={<div className="p-8 text-center">Loading...</div>}>
<NewNotePage />
</Suspense>
);
}
function NewNotePage() {
const { request } = useApi();
const router = useRouter();
const searchParams = useSearchParams();
const isAudio = searchParams.get("type") === "audio";
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [transcribing, setTranscribing] = useState(false);
const [transcription, setTranscription] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSaveText = async () => {
if (!title.trim() && !content.trim()) return;
setSaving(true);
try {
await request({ method: "POST", url: "/api/notes", data: { title, content } });
router.push("/notes");
} catch (err) {
console.error("Failed to save note:", err);
alert("노트 저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
const handleUploadAudio = async () => {
if (!audioFile) return;
setTranscribing(true);
try {
const formData = new FormData();
formData.append("file", audioFile);
if (title.trim()) formData.append("title", title);
const result = await request<{ id: string; transcription: string }>({
method: "POST",
url: "/api/notes/audio",
data: formData,
headers: { "Content-Type": "multipart/form-data" },
});
setTranscription(result.transcription);
router.push(`/notes/${result.id}`);
} catch (err) {
console.error("Failed to transcribe audio:", err);
alert("음성 변환에 실패했습니다.");
} finally {
setTranscribing(false);
}
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<button
onClick={() => router.push("/notes")}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
>
&larr; Back to Notes
</button>
<h1 className="text-2xl font-bold mb-6">
{isAudio ? "음성 노트 작성" : "텍스트 노트 작성"}
</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] space-y-4">
{/* 제목 */}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isAudio ? "자동 생성됩니다 (선택)" : "노트 제목"}
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"
/>
</div>
{isAudio ? (
/* 음성 업로드 */
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> </label>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
className="hidden"
/>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-[var(--color-border)] rounded-lg p-8 text-center cursor-pointer hover:border-[var(--color-primary)] transition-colors"
>
{audioFile ? (
<div>
<p className="font-medium">{audioFile.name}</p>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
{(audioFile.size / 1024 / 1024).toFixed(1)} MB
</p>
</div>
) : (
<div>
<p className="text-[var(--color-text-muted)]"> </p>
<p className="text-xs text-[var(--color-text-muted)] mt-1">MP3, WAV, M4A, OGG, WebM</p>
</div>
)}
</div>
{transcription && (
<div className="mt-4 p-4 bg-[var(--color-bg-hover)] rounded-lg">
<p className="text-sm text-[var(--color-text-muted)] mb-2"> :</p>
<p className="text-sm whitespace-pre-wrap">{transcription}</p>
</div>
)}
<button
onClick={handleUploadAudio}
disabled={!audioFile || transcribing}
className="mt-4 px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors disabled:opacity-40"
>
{transcribing ? "변환 중..." : "음성 변환 및 저장"}
</button>
</div>
) : (
/* 텍스트 입력 */
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="노트 내용을 입력하세요..."
rows={15}
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"
/>
<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"
>
{saving ? "저장 중..." : "저장"}
</button>
</div>
)}
</div>
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Note {
ID: string;
TITLE: string;
NOTE_TYPE: string;
CATEGORY_PATH: string | null;
CREATED_AT: string;
UPDATED_AT: string;
}
export default function NotesPage() {
const { request } = useApi();
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const data = await request<Note[]>({ method: "GET", url: "/api/notes" });
setNotes(data);
} catch (err) {
console.error("Failed to load notes:", err);
} finally {
setLoading(false);
}
})();
}, []);
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Notes</h1>
<div className="flex gap-2">
<Link
href="/notes/new"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+
</Link>
<Link
href="/notes/new?type=audio"
className="px-4 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors text-sm"
>
+
</Link>
</div>
</div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : notes.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]"> . .</p>
</div>
) : (
<div className="space-y-3">
{notes.map((note) => (
<Link
key={note.ID}
href={`/notes/${note.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
note.NOTE_TYPE === "TRANSCRIBING" ? "bg-yellow-500/20 text-yellow-400" :
note.NOTE_TYPE === "AUDIO_FAILED" ? "bg-red-500/20 text-red-400" :
note.NOTE_TYPE === "AUDIO" ? "bg-purple-500/20 text-purple-400" :
"bg-blue-500/20 text-blue-400"
}`}>
{note.NOTE_TYPE === "TRANSCRIBING" ? "변환 중..." :
note.NOTE_TYPE === "AUDIO_FAILED" ? "변환 실패" :
note.NOTE_TYPE === "AUDIO" ? "음성" : "텍스트"}
</span>
{note.CATEGORY_PATH && (
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
{note.CATEGORY_PATH}
</span>
)}
</div>
<h3 className="font-medium truncate">{note.TITLE || "제목 없음"}</h3>
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(note.CREATED_AT).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,302 @@
"use client";
import { useState, useRef, useEffect } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
interface VoiceProfile {
id: string;
name: string;
ref_text?: string;
}
export default function TTSPage() {
// 녹음
const [isRecording, setIsRecording] = useState(false);
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
const [recordedUrl, setRecordedUrl] = useState<string | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
// 프로필
const [profiles, setProfiles] = useState<VoiceProfile[]>([]);
const [selectedProfile, setSelectedProfile] = useState<string>("");
const [profileName, setProfileName] = useState("");
const [refText, setRefText] = useState("");
const [registering, setRegistering] = useState(false);
// TTS
const [text, setText] = useState("");
const [language, setLanguage] = useState("korean");
const [generating, setGenerating] = useState(false);
const [outputUrl, setOutputUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [serverStatus, setServerStatus] = useState("checking...");
// 탭
const [tab, setTab] = useState<"generate" | "register">("generate");
useEffect(() => {
fetch("/api/tts/health").then(r => r.json())
.then(d => setServerStatus(d.model_loaded ? "ready" : "loading..."))
.catch(() => setServerStatus("offline"));
fetchProfiles();
}, []);
const fetchProfiles = () => {
fetch("/api/tts/profiles").then(r => r.json())
.then(setProfiles).catch(() => {});
};
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
mediaRecorderRef.current = mr;
chunksRef.current = [];
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
mr.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
setRecordedBlob(blob);
setRecordedUrl(URL.createObjectURL(blob));
setUploadedFile(null);
stream.getTracks().forEach(t => t.stop());
};
mr.start();
setIsRecording(true);
} catch (err) {
setError("마이크 접근 실패");
}
};
const stopRecording = () => { mediaRecorderRef.current?.stop(); setIsRecording(false); };
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) { setUploadedFile(file); setRecordedBlob(null); setRecordedUrl(URL.createObjectURL(file)); }
};
// 프로필 등록
const handleRegister = async () => {
const audio = uploadedFile || recordedBlob;
if (!audio || !profileName.trim()) return;
setRegistering(true);
setError(null);
try {
const fd = new FormData();
fd.append("name", profileName);
fd.append("ref_audio", audio, uploadedFile?.name || "recording.webm");
if (refText.trim()) fd.append("ref_text", refText);
const res = await fetch("/api/tts/profiles", { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const result = await res.json();
setProfileName("");
setRefText("");
setRecordedBlob(null);
setRecordedUrl(null);
setUploadedFile(null);
fetchProfiles();
setSelectedProfile(result.id);
setTab("generate");
} catch (err) {
setError("프로필 등록 실패: " + (err instanceof Error ? err.message : ""));
} finally {
setRegistering(false);
}
};
// 프로필 삭제
const handleDeleteProfile = async (id: string) => {
if (!confirm("삭제하시겠습니까?")) return;
await fetch(`/api/tts/profiles/${id}`, { method: "DELETE" });
fetchProfiles();
if (selectedProfile === id) setSelectedProfile("");
};
// TTS 생성 (프로필 기반)
const handleGenerate = async () => {
if (!text.trim() || !selectedProfile) return;
setGenerating(true);
setError(null);
setOutputUrl(null);
try {
const fd = new FormData();
fd.append("text", text);
fd.append("profile_id", selectedProfile);
fd.append("language", language);
const res = await fetch("/api/tts/generate", { method: "POST", body: fd });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
setOutputUrl(URL.createObjectURL(blob));
} catch (err) {
setError("생성 실패: " + (err instanceof Error ? err.message : ""));
} finally {
setGenerating(false);
}
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Voice Clone (TTS)</h1>
<span className={`text-xs px-2 py-1 rounded ${
serverStatus === "ready" ? "bg-green-500/20 text-green-400" :
serverStatus === "offline" ? "bg-red-500/20 text-red-400" :
"bg-yellow-500/20 text-yellow-400"
}`}>{serverStatus}</span>
</div>
{/* 탭 */}
<div className="flex gap-2 mb-6">
<button onClick={() => setTab("generate")}
className={`px-4 py-2 text-sm rounded-lg ${tab === "generate" ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-card)] border border-[var(--color-border)]"}`}>
</button>
<button onClick={() => setTab("register")}
className={`px-4 py-2 text-sm rounded-lg ${tab === "register" ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-card)] border border-[var(--color-border)]"}`}>
</button>
</div>
{tab === "register" ? (
/* 프로필 등록 */
<div className="space-y-6">
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
15~30 .
</p>
<div className="space-y-4">
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> </label>
<input type="text" value={profileName} onChange={e => setProfileName(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 className="flex gap-3">
{isRecording ? (
<button onClick={stopRecording}
className="px-4 py-2 bg-red-500 hover:bg-red-600 rounded-lg text-sm flex items-center gap-2">
<span className="w-3 h-3 bg-white rounded-full animate-pulse" />
</button>
) : (
<button onClick={startRecording}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm">
</button>
)}
<label className="px-4 py-2 bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg text-sm cursor-pointer hover:border-[var(--color-primary)]">
<input type="file" accept="audio/*" onChange={handleFileUpload} className="hidden" />
</label>
</div>
{recordedUrl && (
<audio controls src={recordedUrl} className="w-full" />
)}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1"> ( - )</label>
<input type="text" value={refText} onChange={e => setRefText(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>
<button onClick={handleRegister}
disabled={registering || !profileName.trim() || !(uploadedFile || recordedBlob)}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg disabled:opacity-40">
{registering ? "등록 중..." : "프로필 등록"}
</button>
</div>
</div>
{/* 등록된 프로필 목록 */}
{profiles.length > 0 && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<div className="space-y-2">
{profiles.map(p => (
<div key={p.id} className="flex items-center justify-between p-3 bg-[var(--color-bg-hover)] rounded-lg">
<span className="text-sm font-medium">{p.name}</span>
<button onClick={() => handleDeleteProfile(p.id)}
className="text-xs text-red-400 hover:text-red-300"></button>
</div>
))}
</div>
</div>
)}
</div>
) : (
/* 음성 생성 */
<div className="space-y-6">
{/* 프로필 선택 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4">1. </h2>
{profiles.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">
.{" "}
<button onClick={() => setTab("register")} className="text-[var(--color-primary)] hover:underline">
</button>
</p>
) : (
<div className="flex flex-wrap gap-2">
{profiles.map(p => (
<button key={p.id} onClick={() => setSelectedProfile(p.id)}
className={`px-4 py-2 text-sm rounded-lg transition-colors ${
selectedProfile === p.id
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-hover)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}>
{p.name}
</button>
))}
</div>
)}
</div>
{/* 텍스트 입력 */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4">2. </h2>
<select value={language} onChange={e => setLanguage(e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm mb-3">
<option value="korean"></option>
<option value="english">English</option>
<option value="japanese"></option>
<option value="chinese"></option>
</select>
<textarea value={text} onChange={e => setText(e.target.value)}
placeholder="음성으로 변환할 텍스트를 입력하세요..." rows={5}
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 resize-y" />
</div>
{/* 생성 */}
<button onClick={handleGenerate}
disabled={generating || !text.trim() || !selectedProfile || serverStatus !== "ready"}
className="w-full py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl text-lg font-semibold disabled:opacity-40">
{generating ? "생성 중..." : "음성 생성"}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 text-sm text-red-400">{error}</div>
)}
{outputUrl && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-4"> </h2>
<audio controls src={outputUrl} className="w-full mb-3" />
<a href={outputUrl} download="tts_output.wav"
className="text-sm text-[var(--color-primary)] hover:underline"></a>
</div>
)}
</div>
)}
</main>
</AuthGuard>
);
}

View File

@@ -7,10 +7,12 @@ import { useAuth } from "@/lib/auth-context";
const navItems = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/knowledge", label: "Knowledge" },
{ href: "/notes", label: "Notes" },
{ href: "/chat", label: "Chat" },
{ href: "/study", label: "Study" },
{ href: "/todos", label: "Todos" },
{ href: "/habits", label: "Habits" },
{ href: "/tts", label: "TTS" },
{ href: "/settings", label: "Settings" },
];

View File

@@ -5,7 +5,7 @@ export const api = axios.create({
withCredentials: true,
});
// --- 공통 토큰 refresh 로직 (mutex 패턴) ---
// --- refresh 로직 ---
let isRefreshing = false;
let pendingQueue: {
@@ -13,7 +13,6 @@ let pendingQueue: {
reject: (error: unknown) => void;
}[] = [];
// auth-context에서 주입하는 콜백
let onTokenRefreshed: ((token: string) => void) | null = null;
let onRefreshFailed: (() => void) | null = null;
@@ -27,21 +26,27 @@ export function setAuthCallbacks(
function processQueue(token: string | null, error: unknown) {
pendingQueue.forEach(({ resolve, reject }) => {
if (token) {
resolve(token);
} else {
reject(error);
}
if (token) resolve(token);
else reject(error);
});
pendingQueue = [];
}
// 요청 인터셉터: 매 요청마다 localStorage에서 최신 토큰 읽기
api.interceptors.request.use((config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
// 응답 인터셉터: 401이면 refresh 후 재시도
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// 401이 아니거나, refresh 요청 자체가 실패한 경우, 이미 retry한 경우 → 그냥 throw
if (
error.response?.status !== 401 ||
originalRequest.url?.includes("/api/auth/") ||
@@ -50,7 +55,6 @@ api.interceptors.response.use(
return Promise.reject(error);
}
// 이미 refresh 진행 중이면 큐에 대기
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({
@@ -64,36 +68,25 @@ api.interceptors.response.use(
});
}
// refresh 시작
isRefreshing = true;
originalRequest._retry = true;
const attemptRefresh = async (retryCount: number): Promise<string> => {
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
return res.data.accessToken;
} catch (err) {
const isNetworkError = !((err as AxiosError).response);
if (isNetworkError && retryCount < 2) {
// 네트워크 에러(서버 재시작 등)면 3초 후 재시도
await new Promise((r) => setTimeout(r, 3000));
return attemptRefresh(retryCount + 1);
}
throw err;
}
};
try {
const newToken = await attemptRefresh(0);
const rt = localStorage.getItem("refreshToken");
if (!rt) throw new Error("No refresh token");
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
onTokenRefreshed?.(newToken);
const res = await api.post<LoginResponse>("/api/auth/refresh", { refreshToken: rt });
const newAccess = res.data.accessToken;
const newRefresh = res.data.refreshToken;
// 대기 중인 요청들 처리
processQueue(newToken, null);
localStorage.setItem("accessToken", newAccess);
if (newRefresh) localStorage.setItem("refreshToken", newRefresh);
api.defaults.headers.common["Authorization"] = `Bearer ${newAccess}`;
// 원래 요청 retry
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
onTokenRefreshed?.(newAccess);
processQueue(newAccess, null);
originalRequest.headers["Authorization"] = `Bearer ${newAccess}`;
return api.request(originalRequest);
} catch (refreshError) {
processQueue(null, refreshError);

View File

@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import { api, LoginResponse, setAuthCallbacks } from "./api";
interface AuthContextType {
@@ -21,93 +21,60 @@ const AuthContext = createContext<AuthContextType>({
setAccessToken: () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logoutRef = useRef<() => void>(() => {});
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("accessToken");
}
// localStorage와 동기화하는 setter
const setAccessToken = useCallback((token: string | null) => {
setAccessTokenState(token);
if (token) {
localStorage.setItem("accessToken", token);
} else {
localStorage.removeItem("accessToken");
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessTokenState] = useState<string | null>(getStoredToken);
const [isLoading, setIsLoading] = useState(false);
const saveTokens = useCallback((access: string, refresh?: string) => {
setAccessTokenState(access);
localStorage.setItem("accessToken", access);
if (refresh) localStorage.setItem("refreshToken", refresh);
api.defaults.headers.common["Authorization"] = `Bearer ${access}`;
}, []);
const clearTokens = useCallback(() => {
setAccessTokenState(null);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
delete api.defaults.headers.common["Authorization"];
}, []);
const login = useCallback((response: LoginResponse) => {
saveTokens(response.accessToken, response.refreshToken);
}, [saveTokens]);
const logout = useCallback(async () => {
try { await api.post("/api/auth/logout"); } catch {}
clearTokens();
window.location.href = "/login";
}, [clearTokens]);
// 인터셉터 콜백: 토큰 갱신 성공/실패 처리
useEffect(() => {
setAuthCallbacks(
(token: string) => {
setAccessTokenState(token);
localStorage.setItem("accessToken", token);
},
() => logout()
);
}, [logout]);
// 앱 로드 시 localStorage 토큰으로 헤더 설정
useEffect(() => {
const access = localStorage.getItem("accessToken");
if (access) {
api.defaults.headers.common["Authorization"] = `Bearer ${access}`;
}
}, []);
// interceptor 콜백 등록
useEffect(() => {
setAuthCallbacks(
(token: string) => setAccessToken(token),
() => logoutRef.current()
);
}, [setAccessToken]);
useEffect(() => {
const restoreSession = async () => {
// 1차: localStorage에서 복원
const stored = localStorage.getItem("accessToken");
if (stored) {
setAccessTokenState(stored);
api.defaults.headers.common["Authorization"] = `Bearer ${stored}`;
setIsLoading(false);
return;
}
// 2차: refresh token cookie로 복원
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
const token = res.data.accessToken;
setAccessToken(token);
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} catch {
// No valid session
} finally {
setIsLoading(false);
}
};
restoreSession();
}, [setAccessToken]);
useEffect(() => {
if (accessToken) {
api.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`;
} else {
delete api.defaults.headers.common["Authorization"];
}
}, [accessToken]);
const login = useCallback((response: LoginResponse) => {
setAccessToken(response.accessToken);
}, [setAccessToken]);
const logout = useCallback(async () => {
try {
await api.post("/api/auth/logout");
} catch {
// Ignore logout errors
}
setAccessToken(null);
window.location.href = "/login";
}, [setAccessToken]);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {
logoutRef.current = logout;
}, [logout]);
return (
<AuthContext.Provider
value={{
isAuthenticated: !!accessToken,
isLoading,
accessToken,
login,
logout,
setAccessToken,
}}
>
<AuthContext.Provider value={{ isAuthenticated: !!accessToken, isLoading, accessToken, login, logout, setAccessToken: (t) => saveTokens(t) }}>
{children}
</AuthContext.Provider>
);