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:
275
sundol-frontend/src/app/notes/[id]/page.tsx
Normal file
275
sundol-frontend/src/app/notes/[id]/page.tsx
Normal 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">
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
166
sundol-frontend/src/app/notes/new/page.tsx
Normal file
166
sundol-frontend/src/app/notes/new/page.tsx
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
104
sundol-frontend/src/app/notes/page.tsx
Normal file
104
sundol-frontend/src/app/notes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
sundol-frontend/src/app/tts/page.tsx
Normal file
302
sundol-frontend/src/app/tts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user