Implement all core features: Knowledge pipeline, RAG chat, Todos, Habits, Study Cards, Tags, Dashboard
- Google OAuth authentication with callback flow - Knowledge ingest pipeline (TEXT/WEB/YOUTUBE → chunking → categorization → embedding) - OCI GenAI integration (chat, embeddings) with multi-model support - Semantic search via Oracle VECTOR_DISTANCE - RAG-based AI chat with source attribution - Todos with subtasks, filters, and priority levels - Habits with daily check-in, streak tracking, and color customization - Study Cards with SM-2 spaced repetition and LLM auto-generation - Tags system with knowledge item mapping - Dashboard with live data from all modules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
sundol-frontend/next-env.d.ts
vendored
Normal file
6
sundol-frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7411
sundol-frontend/package-lock.json
generated
Normal file
7411
sundol-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
import { useApi } from "@/lib/use-api";
|
||||
|
||||
interface Session {
|
||||
ID: string;
|
||||
TITLE: string;
|
||||
CREATED_AT: string;
|
||||
UPDATED_AT: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
ID: string;
|
||||
ROLE: string;
|
||||
CONTENT: string;
|
||||
SOURCE_CHUNKS: string | null;
|
||||
CREATED_AT: string;
|
||||
}
|
||||
|
||||
interface SourceChunk {
|
||||
knowledgeItemId: string;
|
||||
title: string;
|
||||
chunkIndex: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { request } = useApi();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// Load sessions
|
||||
useEffect(() => {
|
||||
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
|
||||
.then(setSessions)
|
||||
.catch((err) => console.error("Failed to load sessions:", err))
|
||||
.finally(() => setLoadingSessions(false));
|
||||
}, []);
|
||||
|
||||
// Load messages when session changes
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
request<Message[]>({ method: "GET", url: `/api/chat/sessions/${activeSessionId}/messages` })
|
||||
.then(setMessages)
|
||||
.catch((err) => console.error("Failed to load messages:", err));
|
||||
}, [activeSessionId]);
|
||||
|
||||
const handleNewSession = async () => {
|
||||
try {
|
||||
const session = await request<Session>({ method: "POST", url: "/api/chat/sessions" });
|
||||
setSessions((prev) => [session, ...prev]);
|
||||
setActiveSessionId(session.ID);
|
||||
} catch (err) {
|
||||
console.error("Failed to create session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
try {
|
||||
await request({ method: "DELETE", url: `/api/chat/sessions/${sessionId}` });
|
||||
setSessions((prev) => prev.filter((s) => s.ID !== sessionId));
|
||||
if (activeSessionId === sessionId) {
|
||||
setActiveSessionId(null);
|
||||
setMessages([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || sending || !activeSessionId) return;
|
||||
|
||||
const userMessage = input.trim();
|
||||
setInput("");
|
||||
setSending(true);
|
||||
|
||||
// Optimistic UI: add user message immediately
|
||||
const tempUserMsg: Message = {
|
||||
ID: "temp-user",
|
||||
ROLE: "user",
|
||||
CONTENT: userMessage,
|
||||
SOURCE_CHUNKS: null,
|
||||
CREATED_AT: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempUserMsg]);
|
||||
|
||||
try {
|
||||
const response = await request<{ role: string; content: string; sourceChunks: string }>({
|
||||
method: "POST",
|
||||
url: `/api/chat/sessions/${activeSessionId}/messages`,
|
||||
data: { content: userMessage },
|
||||
});
|
||||
|
||||
const assistantMsg: Message = {
|
||||
ID: "temp-assistant-" + Date.now(),
|
||||
ROLE: "assistant",
|
||||
CONTENT: response.content,
|
||||
SOURCE_CHUNKS: response.sourceChunks,
|
||||
CREATED_AT: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// Refresh sessions for updated title
|
||||
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
|
||||
.then(setSessions)
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
const errorMsg: Message = {
|
||||
ID: "temp-error",
|
||||
ROLE: "assistant",
|
||||
CONTENT: "메시지 전송에 실패했습니다.",
|
||||
SOURCE_CHUNKS: null,
|
||||
CREATED_AT: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const parseSourceChunks = (json: string | null): SourceChunk[] => {
|
||||
if (!json) return [];
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">AI Chat</h1>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] min-h-[60vh] flex items-center justify-center">
|
||||
<p className="text-[var(--color-text-muted)]">Start a new conversation to ask questions about your knowledge base.</p>
|
||||
<main className="max-w-7xl mx-auto px-4 py-4 h-[calc(100vh-64px)] flex gap-4">
|
||||
{/* Sidebar: Sessions */}
|
||||
<div className="w-64 flex-shrink-0 flex flex-col">
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
className="w-full px-4 py-2 mb-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm transition-colors"
|
||||
>
|
||||
+ New Chat
|
||||
</button>
|
||||
<div className="flex-1 overflow-y-auto space-y-1">
|
||||
{loadingSessions ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] px-2">Loading...</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] px-2">No conversations yet</p>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<div
|
||||
key={s.ID}
|
||||
className={`group flex items-center rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
activeSessionId === s.ID
|
||||
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
|
||||
}`}
|
||||
onClick={() => setActiveSessionId(s.ID)}
|
||||
>
|
||||
<span className="flex-1 truncate">{s.TITLE || "New Chat"}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSession(s.ID);
|
||||
}}
|
||||
className="hidden group-hover:block text-red-400 hover:text-red-300 ml-2 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{!activeSessionId ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold mb-2">AI Chat</h2>
|
||||
<p className="text-[var(--color-text-muted)] mb-4">
|
||||
Knowledge base를 기반으로 질문하세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNewSession}
|
||||
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
Start a new chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-center text-[var(--color-text-muted)] mt-20">
|
||||
Knowledge base에 대해 질문해보세요.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.ID}
|
||||
className={`flex ${msg.ROLE === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[75%] rounded-xl px-4 py-3 ${
|
||||
msg.ROLE === "user"
|
||||
? "bg-[var(--color-primary)] text-white"
|
||||
: "bg-[var(--color-bg-card)] border border-[var(--color-border)]"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-sm">{msg.CONTENT}</p>
|
||||
{/* Source chunks */}
|
||||
{msg.ROLE === "assistant" && (() => {
|
||||
const sources = parseSourceChunks(msg.SOURCE_CHUNKS);
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 pt-2 border-t border-[var(--color-border)]">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">참조:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sources.map((src, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={`/knowledge/${src.knowledgeItemId}`}
|
||||
className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
|
||||
>
|
||||
{src.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sending && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[var(--color-bg-card)] border border-[var(--color-border)] rounded-xl px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="메시지를 입력하세요..."
|
||||
disabled={sending}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sending}
|
||||
className="px-6 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-xl transition-colors"
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
|
||||
@@ -1,33 +1,81 @@
|
||||
"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 DashData {
|
||||
knowledgeCount: number;
|
||||
dueCards: number;
|
||||
activeTodos: number;
|
||||
habitStreaks: number;
|
||||
chatSessions: number;
|
||||
tags: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { request } = useApi();
|
||||
const [data, setData] = useState<DashData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<unknown[]>({ method: "GET", url: "/api/knowledge" }).catch(() => []),
|
||||
request<unknown[]>({ method: "GET", url: "/api/study-cards/due" }).catch(() => []),
|
||||
request<unknown[]>({ method: "GET", url: "/api/todos?status=PENDING" }).catch(() => []),
|
||||
request<{ STREAK_CURRENT?: number }[]>({ method: "GET", url: "/api/habits" }).catch(() => []),
|
||||
request<unknown[]>({ method: "GET", url: "/api/chat/sessions" }).catch(() => []),
|
||||
request<unknown[]>({ method: "GET", url: "/api/tags" }).catch(() => []),
|
||||
]).then(([knowledge, dueCards, todos, habits, sessions, tags]) => {
|
||||
const activeStreaks = (habits as { STREAK_CURRENT?: number }[]).filter(
|
||||
(h) => h.STREAK_CURRENT && h.STREAK_CURRENT > 0
|
||||
).length;
|
||||
setData({
|
||||
knowledgeCount: knowledge.length,
|
||||
dueCards: dueCards.length,
|
||||
activeTodos: todos.length,
|
||||
habitStreaks: activeStreaks,
|
||||
chatSessions: sessions.length,
|
||||
tags: tags.length,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cards: { title: string; value: string; description: string; href: string; color: string }[] = data
|
||||
? [
|
||||
{ title: "Knowledge Items", value: String(data.knowledgeCount), description: "수집된 항목", href: "/knowledge", color: "text-blue-400" },
|
||||
{ title: "Due Study Cards", value: String(data.dueCards), description: "복습 대기 카드", href: "/study", color: "text-purple-400" },
|
||||
{ title: "Active Todos", value: String(data.activeTodos), description: "진행중인 할 일", href: "/todos", color: "text-yellow-400" },
|
||||
{ title: "Habit Streaks", value: String(data.habitStreaks), description: "활성 연속 기록", href: "/habits", color: "text-green-400" },
|
||||
{ title: "Chat Sessions", value: String(data.chatSessions), description: "대화 세션", href: "/chat", color: "text-cyan-400" },
|
||||
{ title: "Tags", value: String(data.tags), description: "태그 수", href: "#", color: "text-indigo-400" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<DashCard title="Knowledge Items" value="-" description="Total ingested items" />
|
||||
<DashCard title="Due Study Cards" value="-" description="Cards due for review" />
|
||||
<DashCard title="Active Todos" value="-" description="Pending tasks" />
|
||||
<DashCard title="Habit Streaks" value="-" description="Current active streaks" />
|
||||
<DashCard title="Chat Sessions" value="-" description="Active conversations" />
|
||||
<DashCard title="Tags" value="-" description="Knowledge categories" />
|
||||
</div>
|
||||
{!data ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.title}
|
||||
href={card.href}
|
||||
className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{card.title}</h3>
|
||||
<p className={`text-3xl font-bold mb-1 ${card.color}`}>{card.value}</p>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{card.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function DashCard({ title, value, description }: { title: string; value: string; description: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{title}</h3>
|
||||
<p className="text-3xl font-bold mb-1">{value}</p>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
import { useApi } from "@/lib/use-api";
|
||||
|
||||
interface Habit {
|
||||
ID: string;
|
||||
NAME: string;
|
||||
DESCRIPTION: string | null;
|
||||
HABIT_TYPE: string;
|
||||
COLOR: string;
|
||||
STREAK_CURRENT: number;
|
||||
STREAK_BEST: number;
|
||||
CHECKED_TODAY: boolean;
|
||||
}
|
||||
|
||||
const COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ef4444"];
|
||||
|
||||
export default function HabitsPage() {
|
||||
const { request } = useApi();
|
||||
const [habits, setHabits] = useState<Habit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(COLORS[0]);
|
||||
|
||||
const fetchHabits = async () => {
|
||||
try {
|
||||
const data = await request<Habit[]>({ method: "GET", url: "/api/habits" });
|
||||
setHabits(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load habits:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits();
|
||||
}, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await request({
|
||||
method: "POST",
|
||||
url: "/api/habits",
|
||||
data: { name: newName.trim(), habitType: "DAILY", color: newColor },
|
||||
});
|
||||
setNewName("");
|
||||
setShowAdd(false);
|
||||
fetchHabits();
|
||||
} catch (err) {
|
||||
console.error("Failed to create habit:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckin = async (habitId: string) => {
|
||||
try {
|
||||
await request({ method: "POST", url: `/api/habits/${habitId}/checkin`, data: {} });
|
||||
fetchHabits();
|
||||
} catch (err) {
|
||||
console.error("Failed to check in:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (habitId: string) => {
|
||||
if (!confirm("이 습관을 삭제하시겠습니까?")) return;
|
||||
try {
|
||||
await request({ method: "DELETE", url: `/api/habits/${habitId}` });
|
||||
fetchHabits();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete habit:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 오늘 요일 (월~일 한글)
|
||||
const today = new Date();
|
||||
const weekDays = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{today.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "long" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
|
||||
>
|
||||
+ Add Habit
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<p className="text-[var(--color-text-muted)]">No habits tracked yet. Start building good habits.</p>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showAdd && (
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="습관 이름 (예: 물 2L 마시기)"
|
||||
autoFocus
|
||||
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 className="flex items-center gap-3">
|
||||
<span className="text-sm text-[var(--color-text-muted)]">색상:</span>
|
||||
<div className="flex gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? "scale-125 ring-2 ring-white" : ""}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim()}
|
||||
className="ml-auto px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Habit list */}
|
||||
{loading ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : habits.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">
|
||||
{habits.map((habit) => (
|
||||
<div
|
||||
key={habit.ID}
|
||||
className="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 gap-4">
|
||||
{/* Check-in button */}
|
||||
<button
|
||||
onClick={() => !habit.CHECKED_TODAY && handleCheckin(habit.ID)}
|
||||
disabled={habit.CHECKED_TODAY}
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
habit.CHECKED_TODAY
|
||||
? "opacity-90"
|
||||
: "opacity-50 hover:opacity-100 hover:scale-105"
|
||||
}`}
|
||||
style={{ backgroundColor: habit.COLOR || "#6366f1" }}
|
||||
>
|
||||
{habit.CHECKED_TODAY ? (
|
||||
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-white text-lg font-bold">+</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium">{habit.NAME}</h3>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-sm" style={{ color: habit.COLOR || "#6366f1" }}>
|
||||
{habit.STREAK_CURRENT > 0 ? `${habit.STREAK_CURRENT}일 연속` : "시작 전"}
|
||||
</span>
|
||||
{habit.STREAK_BEST > 0 && (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
최고: {habit.STREAK_BEST}일
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => handleDelete(habit.ID)}
|
||||
className="text-xs text-red-400 hover:text-red-300 px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
367
sundol-frontend/src/app/knowledge/[id]/page.tsx
Normal file
367
sundol-frontend/src/app/knowledge/[id]/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
"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";
|
||||
|
||||
interface Category {
|
||||
ID: string;
|
||||
NAME: string;
|
||||
DEPTH: number;
|
||||
FULL_PATH: string;
|
||||
}
|
||||
|
||||
interface KnowledgeItem {
|
||||
ID: string;
|
||||
TYPE: string;
|
||||
TITLE: string;
|
||||
SOURCE_URL: string;
|
||||
RAW_TEXT: string;
|
||||
STATUS: string;
|
||||
CREATED_AT: string;
|
||||
UPDATED_AT: string;
|
||||
CATEGORIES: Category[];
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
ID: string;
|
||||
CHUNK_INDEX: number;
|
||||
CONTENT: string;
|
||||
TOKEN_COUNT: number;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "bg-yellow-500/20 text-yellow-400",
|
||||
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
||||
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||
READY: "bg-green-500/20 text-green-400",
|
||||
FAILED: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
YOUTUBE: "YouTube",
|
||||
WEB: "Web",
|
||||
TEXT: "Text",
|
||||
};
|
||||
|
||||
function extractYouTubeVideoId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "youtu.be") return u.pathname.slice(1);
|
||||
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
|
||||
} catch {
|
||||
// invalid URL
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function KnowledgeDetailPage() {
|
||||
const { request } = useApi();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [item, setItem] = useState<KnowledgeItem | null>(null);
|
||||
const [chunks, setChunks] = useState<Chunk[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [showChunks, setShowChunks] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const fetchItem = async () => {
|
||||
try {
|
||||
const data = await request<KnowledgeItem>({ method: "GET", url: `/api/knowledge/${id}` });
|
||||
setItem(data);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchChunks = async () => {
|
||||
try {
|
||||
const data = await request<Chunk[]>({ method: "GET", url: `/api/knowledge/${id}/chunks` });
|
||||
setChunks(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load chunks:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItem();
|
||||
}, [id]);
|
||||
|
||||
// Poll while processing
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
const processing = ["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
|
||||
if (!processing) return;
|
||||
const interval = setInterval(fetchItem, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [item?.STATUS]);
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!titleDraft.trim()) return;
|
||||
try {
|
||||
const updated = await request<KnowledgeItem>({
|
||||
method: "PATCH",
|
||||
url: `/api/knowledge/${id}`,
|
||||
data: { title: titleDraft.trim() },
|
||||
});
|
||||
setItem(updated);
|
||||
setEditingTitle(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to update title:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await request({ method: "DELETE", url: `/api/knowledge/${id}` });
|
||||
router.push("/knowledge");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleChunks = () => {
|
||||
if (!showChunks && chunks.length === 0) {
|
||||
fetchChunks();
|
||||
}
|
||||
setShowChunks(!showChunks);
|
||||
};
|
||||
|
||||
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 (error || !item) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<p className="text-red-400">{error || "Item not found"}</p>
|
||||
<button
|
||||
onClick={() => router.push("/knowledge")}
|
||||
className="mt-4 text-sm text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
← Back to Knowledge
|
||||
</button>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
const videoId = item.TYPE === "YOUTUBE" && item.SOURCE_URL ? extractYouTubeVideoId(item.SOURCE_URL) : null;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Back link */}
|
||||
<button
|
||||
onClick={() => router.push("/knowledge")}
|
||||
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
|
||||
>
|
||||
← Back to Knowledge
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
|
||||
{typeLabels[item.TYPE] || item.TYPE}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
|
||||
{item.STATUS}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title (editable) */}
|
||||
{editingTitle ? (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveTitle()}
|
||||
className="flex-1 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-lg font-bold"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveTitle}
|
||||
className="px-3 py-1 text-sm bg-[var(--color-primary)] rounded-lg"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTitle(false)}
|
||||
className="px-3 py-1 text-sm bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
className="text-xl font-bold mb-3 cursor-pointer hover:text-[var(--color-primary)] transition-colors"
|
||||
onClick={() => {
|
||||
setTitleDraft(item.TITLE || "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="클릭하여 제목 수정"
|
||||
>
|
||||
{item.TITLE || "Untitled"}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{/* Source URL */}
|
||||
{item.SOURCE_URL && (
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-3 break-all">
|
||||
<a href={item.SOURCE_URL} target="_blank" rel="noopener noreferrer" className="hover:text-[var(--color-primary)]">
|
||||
{item.SOURCE_URL}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||
<span>생성: {new Date(item.CREATED_AT).toLocaleString("ko-KR")}</span>
|
||||
<span>수정: {new Date(item.UPDATED_AT).toLocaleString("ko-KR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YouTube Embed */}
|
||||
{videoId && (
|
||||
<div className="rounded-xl overflow-hidden border border-[var(--color-border)] mb-6">
|
||||
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||
<iframe
|
||||
className="absolute inset-0 w-full h-full"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{/* Categories */}
|
||||
{item.CATEGORIES && item.CATEGORIES.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{item.CATEGORIES.map((cat) => (
|
||||
<span
|
||||
key={cat.ID}
|
||||
className="text-xs px-2.5 py-1 rounded-full bg-[var(--color-primary)]/15 text-[var(--color-primary)] border border-[var(--color-primary)]/30"
|
||||
>
|
||||
{cat.FULL_PATH}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING"].includes(item.STATUS) && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-blue-400">
|
||||
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
|
||||
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
|
||||
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
|
||||
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
|
||||
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks toggle */}
|
||||
{item.STATUS === "READY" && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleToggleChunks}
|
||||
className="text-sm text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{showChunks ? "▼ 청크 숨기기" : "▶ 청크 보기"}
|
||||
</button>
|
||||
|
||||
{showChunks && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{chunks.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Loading chunks...</p>
|
||||
) : (
|
||||
chunks.map((chunk) => (
|
||||
<div
|
||||
key={chunk.ID}
|
||||
className="bg-[var(--color-bg-card)] rounded-lg p-4 border border-[var(--color-border)]"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
Chunk #{chunk.CHUNK_INDEX}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
~{chunk.TOKEN_COUNT} tokens
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{chunk.CONTENT}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
|
||||
{item.STATUS === "READY" && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await request<{ generated: number }>({
|
||||
method: "POST",
|
||||
url: `/api/study-cards/generate/${id}`,
|
||||
});
|
||||
alert(`${result.generated}개의 스터디 카드가 생성되었습니다.`);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate cards:", err);
|
||||
alert("카드 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}}
|
||||
disabled={generating}
|
||||
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="text-sm text-red-400 hover:text-red-300 disabled:opacity-40"
|
||||
>
|
||||
{deleting ? "삭제 중..." : "삭제"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
240
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
240
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
import { useApi } from "@/lib/use-api";
|
||||
|
||||
type KnowledgeType = "TEXT" | "WEB" | "YOUTUBE";
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string;
|
||||
}
|
||||
|
||||
function extractYouTubeVideoId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "youtu.be") return u.pathname.slice(1);
|
||||
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
|
||||
} catch {
|
||||
// invalid URL
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function KnowledgeAddPage() {
|
||||
const { request } = useApi();
|
||||
const router = useRouter();
|
||||
|
||||
const [type, setType] = useState<KnowledgeType>("TEXT");
|
||||
const [title, setTitle] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [rawText, setRawText] = useState("");
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ models: ModelInfo[]; defaultModel: string; configured: boolean }>({
|
||||
method: "GET",
|
||||
url: "/api/models",
|
||||
}).then((data) => {
|
||||
setModels(data.models);
|
||||
setModelId(data.defaultModel);
|
||||
}).catch((err) => {
|
||||
console.error("Failed to load models:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const videoId = useMemo(() => (type === "YOUTUBE" ? extractYouTubeVideoId(url) : null), [type, url]);
|
||||
|
||||
const canSubmit =
|
||||
!submitting &&
|
||||
((type === "TEXT" && rawText.trim().length > 0) ||
|
||||
(type === "WEB" && url.trim().length > 0) ||
|
||||
(type === "YOUTUBE" && url.trim().length > 0 && rawText.trim().length > 0));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request({
|
||||
method: "POST",
|
||||
url: "/api/knowledge/ingest",
|
||||
data: {
|
||||
type,
|
||||
title: title.trim() || null,
|
||||
url: type !== "TEXT" ? url.trim() : null,
|
||||
rawText: type !== "WEB" ? rawText.trim() : null,
|
||||
modelId: modelId || null,
|
||||
},
|
||||
});
|
||||
router.push("/knowledge");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to submit";
|
||||
setError(msg);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const types: { value: KnowledgeType; label: string }[] = [
|
||||
{ value: "TEXT", label: "Text" },
|
||||
{ value: "WEB", label: "Web" },
|
||||
{ value: "YOUTUBE", label: "YouTube" },
|
||||
];
|
||||
|
||||
// 벤더별 그룹화
|
||||
const groupedModels = useMemo(() => {
|
||||
const groups: Record<string, ModelInfo[]> = {};
|
||||
for (const m of models) {
|
||||
if (!groups[m.vendor]) groups[m.vendor] = [];
|
||||
groups[m.vendor].push(m);
|
||||
}
|
||||
return groups;
|
||||
}, [models]);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Add Knowledge</h1>
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{types.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => {
|
||||
setType(t.value);
|
||||
setUrl("");
|
||||
setRawText("");
|
||||
setError(null);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
type === t.value
|
||||
? "bg-[var(--color-primary)] text-white"
|
||||
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||
제목 (비워두면 AI가 자동 생성)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="비워두면 내용 기반으로 자동 생성"
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL (WEB / YOUTUBE) */}
|
||||
{type !== "TEXT" && (
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--color-text-muted)] mb-1">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={type === "YOUTUBE" ? "https://www.youtube.com/watch?v=..." : "https://example.com/article"}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YouTube Embed */}
|
||||
{type === "YOUTUBE" && videoId && (
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--color-border)]">
|
||||
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||
<iframe
|
||||
className="absolute inset-0 w-full h-full"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Input (TEXT / YOUTUBE) */}
|
||||
{type !== "WEB" && (
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||
{type === "YOUTUBE" ? "Transcript / 내용 붙여넣기" : "텍스트 입력"}
|
||||
</label>
|
||||
<textarea
|
||||
value={rawText}
|
||||
onChange={(e) => setRawText(e.target.value)}
|
||||
placeholder={
|
||||
type === "YOUTUBE"
|
||||
? "영상의 transcript나 내용을 여기에 붙여넣으세요..."
|
||||
: "텍스트를 직접 입력하세요..."
|
||||
}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
{models.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||
AI 모델
|
||||
</label>
|
||||
<select
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||
>
|
||||
{Object.entries(groupedModels).map(([vendor, vendorModels]) => (
|
||||
<optgroup key={vendor} label={vendor}>
|
||||
{vendorModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
{submitting ? "처리 중..." : "추가"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/knowledge")}
|
||||
className="px-6 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,112 @@
|
||||
"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 KnowledgeItem {
|
||||
ID: string;
|
||||
TYPE: string;
|
||||
TITLE: string;
|
||||
SOURCE_URL: string;
|
||||
STATUS: string;
|
||||
CREATED_AT: string;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "bg-yellow-500/20 text-yellow-400",
|
||||
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
||||
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||
READY: "bg-green-500/20 text-green-400",
|
||||
FAILED: "bg-red-500/20 text-red-400",
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
YOUTUBE: "YouTube",
|
||||
WEB: "Web",
|
||||
TEXT: "Text",
|
||||
};
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { request } = useApi();
|
||||
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const data = await request<KnowledgeItem[]>({ method: "GET", url: "/api/knowledge" });
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load knowledge items:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
// Poll for status updates every 5 seconds
|
||||
const interval = setInterval(fetchItems, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Knowledge</h1>
|
||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
||||
<Link
|
||||
href="/knowledge/add"
|
||||
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
|
||||
>
|
||||
+ Add Knowledge
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : items.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)]">No knowledge items yet. Add your first item to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.ID}
|
||||
href={`/knowledge/${item.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-3 mb-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
|
||||
{typeLabels[item.TYPE] || item.TYPE}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
|
||||
{item.STATUS}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-medium truncate">
|
||||
{item.TITLE || item.SOURCE_URL || "Untitled"}
|
||||
</h3>
|
||||
{item.SOURCE_URL && (
|
||||
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
|
||||
{new Date(item.CREATED_AT).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
64
sundol-frontend/src/app/login/callback/page.tsx
Normal file
64
sundol-frontend/src/app/login/callback/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
function CallbackHandler() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const processed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (processed.current) return;
|
||||
processed.current = true;
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
console.error("OAuth error:", error);
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
api.post("/api/auth/google", { code })
|
||||
.then((res) => {
|
||||
login(res.data);
|
||||
router.replace("/dashboard");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Login failed:", err);
|
||||
router.replace("/login");
|
||||
});
|
||||
}, [searchParams, login, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p className="text-[var(--color-text-muted)]">Signing in...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<CallbackHandler />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
const REDIRECT_URI = `${typeof window !== "undefined" ? window.location.origin : ""}/login/callback`;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: Implement Google OAuth flow
|
||||
// For now, placeholder
|
||||
alert("Google OAuth not configured yet");
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
router.replace("/dashboard");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -33,8 +40,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center justify-center gap-3"
|
||||
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors flex items-center justify-center gap-3"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||
@@ -42,7 +48,7 @@ export default function LoginPage() {
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
{isLoading ? "Signing in..." : "Sign in with Google"}
|
||||
Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
import { useApi } from "@/lib/use-api";
|
||||
|
||||
interface StudyCard {
|
||||
ID: string;
|
||||
KNOWLEDGE_ITEM_ID: string | null;
|
||||
KNOWLEDGE_TITLE: string | null;
|
||||
FRONT: string;
|
||||
BACK: string;
|
||||
EASE_FACTOR: number;
|
||||
INTERVAL_DAYS: number;
|
||||
REPETITIONS: number;
|
||||
}
|
||||
|
||||
const ratingButtons = [
|
||||
{ value: 0, label: "모름", color: "bg-red-600 hover:bg-red-500" },
|
||||
{ value: 1, label: "거의 모름", color: "bg-red-500 hover:bg-red-400" },
|
||||
{ value: 2, label: "어려움", color: "bg-orange-500 hover:bg-orange-400" },
|
||||
{ value: 3, label: "보통", color: "bg-yellow-500 hover:bg-yellow-400" },
|
||||
{ value: 4, label: "쉬움", color: "bg-green-500 hover:bg-green-400" },
|
||||
{ value: 5, label: "완벽", color: "bg-green-600 hover:bg-green-500" },
|
||||
];
|
||||
|
||||
export default function StudyPage() {
|
||||
const { request } = useApi();
|
||||
const [cards, setCards] = useState<StudyCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showBack, setShowBack] = useState(false);
|
||||
const [reviewing, setReviewing] = useState(false);
|
||||
|
||||
const fetchDueCards = async () => {
|
||||
try {
|
||||
const data = await request<StudyCard[]>({ method: "GET", url: "/api/study-cards/due" });
|
||||
setCards(data);
|
||||
setCurrentIndex(0);
|
||||
setShowBack(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to load cards:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDueCards();
|
||||
}, []);
|
||||
|
||||
const handleReview = async (rating: number) => {
|
||||
if (reviewing || !cards[currentIndex]) return;
|
||||
setReviewing(true);
|
||||
try {
|
||||
await request({
|
||||
method: "POST",
|
||||
url: `/api/study-cards/${cards[currentIndex].ID}/review`,
|
||||
data: { rating },
|
||||
});
|
||||
|
||||
if (currentIndex + 1 < cards.length) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
setShowBack(false);
|
||||
} else {
|
||||
// 모든 카드 완료
|
||||
setCards([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to review card:", err);
|
||||
} finally {
|
||||
setReviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentCard = cards[currentIndex];
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Study Cards</h1>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] flex items-center justify-center min-h-[40vh]">
|
||||
<p className="text-[var(--color-text-muted)]">No cards due for review. Generate cards from your knowledge items.</p>
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Study Cards</h1>
|
||||
{cards.length > 0 && (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">
|
||||
{currentIndex + 1} / {cards.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-8 border border-[var(--color-border)] text-center">
|
||||
<p className="text-lg mb-2">
|
||||
{currentIndex > 0 ? "복습 완료!" : "복습할 카드가 없습니다."}
|
||||
</p>
|
||||
<p className="text-[var(--color-text-muted)] text-sm">
|
||||
Knowledge 항목에서 카드를 생성해보세요.
|
||||
</p>
|
||||
</div>
|
||||
) : currentCard ? (
|
||||
<div>
|
||||
{/* Source */}
|
||||
{currentCard.KNOWLEDGE_TITLE && (
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-2">
|
||||
출처: {currentCard.KNOWLEDGE_TITLE}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className="bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] min-h-[300px] flex flex-col cursor-pointer"
|
||||
onClick={() => !showBack && setShowBack(true)}
|
||||
>
|
||||
{/* Front */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-3">Question</p>
|
||||
<p className="text-lg whitespace-pre-wrap">{currentCard.FRONT}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back (revealed) */}
|
||||
{showBack && (
|
||||
<div className="border-t border-[var(--color-border)] flex-1 flex items-center justify-center p-8 bg-[var(--color-bg-hover)]">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-3">Answer</p>
|
||||
<p className="text-lg whitespace-pre-wrap">{currentCard.BACK}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!showBack ? (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setShowBack(true)}
|
||||
className="px-8 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl transition-colors"
|
||||
>
|
||||
정답 보기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-[var(--color-text-muted)] text-center mb-3">
|
||||
얼마나 잘 알고 있나요?
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ratingButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.value}
|
||||
onClick={() => handleReview(btn.value)}
|
||||
disabled={reviewing}
|
||||
className={`${btn.color} text-white py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)] transition-all"
|
||||
style={{ width: `${((currentIndex) / cards.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import AuthGuard from "@/components/auth-guard";
|
||||
import NavBar from "@/components/nav-bar";
|
||||
import { useApi } from "@/lib/use-api";
|
||||
|
||||
interface Todo {
|
||||
ID: string;
|
||||
PARENT_ID: string | null;
|
||||
TITLE: string;
|
||||
DESCRIPTION: string | null;
|
||||
STATUS: string;
|
||||
PRIORITY: string;
|
||||
DUE_DATE: string | null;
|
||||
SUBTASK_COUNT: number;
|
||||
SUBTASK_DONE_COUNT: number;
|
||||
}
|
||||
|
||||
interface Subtask {
|
||||
ID: string;
|
||||
TITLE: string;
|
||||
STATUS: string;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
HIGH: "text-red-400",
|
||||
MEDIUM: "text-yellow-400",
|
||||
LOW: "text-green-400",
|
||||
};
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
HIGH: "높음",
|
||||
MEDIUM: "보통",
|
||||
LOW: "낮음",
|
||||
};
|
||||
|
||||
export default function TodosPage() {
|
||||
const { request } = useApi();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"ALL" | "PENDING" | "DONE">("ALL");
|
||||
|
||||
// Add form
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [newPriority, setNewPriority] = useState("MEDIUM");
|
||||
const [newDueDate, setNewDueDate] = useState("");
|
||||
|
||||
// Subtasks
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [newSubtask, setNewSubtask] = useState("");
|
||||
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
const status = filter === "ALL" ? undefined : filter;
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
const data = await request<Todo[]>({ method: "GET", url: `/api/todos?${params}` });
|
||||
setTodos(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load todos:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTodos();
|
||||
}, [filter]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newTitle.trim()) return;
|
||||
try {
|
||||
await request({
|
||||
method: "POST",
|
||||
url: "/api/todos",
|
||||
data: {
|
||||
title: newTitle.trim(),
|
||||
priority: newPriority,
|
||||
dueDate: newDueDate || null,
|
||||
},
|
||||
});
|
||||
setNewTitle("");
|
||||
setNewDueDate("");
|
||||
setShowAdd(false);
|
||||
fetchTodos();
|
||||
} catch (err) {
|
||||
console.error("Failed to create todo:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (todo: Todo) => {
|
||||
const newStatus = todo.STATUS === "DONE" ? "PENDING" : "DONE";
|
||||
try {
|
||||
await request({
|
||||
method: "PATCH",
|
||||
url: `/api/todos/${todo.ID}`,
|
||||
data: { status: newStatus },
|
||||
});
|
||||
fetchTodos();
|
||||
if (expandedId === todo.ID) fetchSubtasks(todo.ID);
|
||||
} catch (err) {
|
||||
console.error("Failed to update todo:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await request({ method: "DELETE", url: `/api/todos/${id}` });
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
fetchTodos();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete todo:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubtasks = async (todoId: string) => {
|
||||
try {
|
||||
const data = await request<Subtask[]>({ method: "GET", url: `/api/todos/${todoId}/subtasks` });
|
||||
setSubtasks(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load subtasks:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = (todoId: string) => {
|
||||
if (expandedId === todoId) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(todoId);
|
||||
fetchSubtasks(todoId);
|
||||
};
|
||||
|
||||
const handleAddSubtask = async () => {
|
||||
if (!newSubtask.trim() || !expandedId) return;
|
||||
try {
|
||||
await request({
|
||||
method: "POST",
|
||||
url: "/api/todos",
|
||||
data: { title: newSubtask.trim(), priority: "MEDIUM", parentId: expandedId },
|
||||
});
|
||||
setNewSubtask("");
|
||||
fetchSubtasks(expandedId);
|
||||
fetchTodos();
|
||||
} catch (err) {
|
||||
console.error("Failed to create subtask:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSubtask = async (subtask: Subtask) => {
|
||||
const newStatus = subtask.STATUS === "DONE" ? "PENDING" : "DONE";
|
||||
try {
|
||||
await request({
|
||||
method: "PATCH",
|
||||
url: `/api/todos/${subtask.ID}`,
|
||||
data: { status: newStatus },
|
||||
});
|
||||
if (expandedId) fetchSubtasks(expandedId);
|
||||
fetchTodos();
|
||||
} catch (err) {
|
||||
console.error("Failed to update subtask:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const isOverdue = (dueDate: string | null) => {
|
||||
if (!dueDate) return false;
|
||||
return new Date(dueDate) < new Date(new Date().toDateString());
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Todos</h1>
|
||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
|
||||
>
|
||||
+ Add Todo
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<p className="text-[var(--color-text-muted)]">No todos yet. Create your first task.</p>
|
||||
|
||||
{/* Add form */}
|
||||
{showAdd && (
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="할 일 입력..."
|
||||
autoFocus
|
||||
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 className="flex gap-3 items-center">
|
||||
<select
|
||||
value={newPriority}
|
||||
onChange={(e) => setNewPriority(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
|
||||
>
|
||||
<option value="HIGH">높음</option>
|
||||
<option value="MEDIUM">보통</option>
|
||||
<option value="LOW">낮음</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={newDueDate}
|
||||
onChange={(e) => setNewDueDate(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!newTitle.trim()}
|
||||
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(["ALL", "PENDING", "DONE"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||
filter === f
|
||||
? "bg-[var(--color-primary)] text-white"
|
||||
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)]"
|
||||
}`}
|
||||
>
|
||||
{f === "ALL" ? "전체" : f === "PENDING" ? "진행중" : "완료"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
{loading ? (
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
) : todos.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)]">
|
||||
{filter === "ALL" ? "아직 할 일이 없습니다." : filter === "PENDING" ? "진행중인 할 일이 없습니다." : "완료된 할 일이 없습니다."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<div key={todo.ID}>
|
||||
<div className="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 gap-3">
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => handleToggleStatus(todo)}
|
||||
className={`w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
|
||||
todo.STATUS === "DONE"
|
||||
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
|
||||
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||
}`}
|
||||
>
|
||||
{todo.STATUS === "DONE" && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${todo.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
|
||||
{todo.TITLE}
|
||||
</span>
|
||||
<span className={`text-xs ${priorityColors[todo.PRIORITY]}`}>
|
||||
{priorityLabels[todo.PRIORITY]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{todo.DUE_DATE && (
|
||||
<span className={`text-xs ${isOverdue(todo.DUE_DATE) && todo.STATUS !== "DONE" ? "text-red-400" : "text-[var(--color-text-muted)]"}`}>
|
||||
{todo.DUE_DATE}
|
||||
</span>
|
||||
)}
|
||||
{todo.SUBTASK_COUNT > 0 && (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{todo.SUBTASK_DONE_COUNT}/{todo.SUBTASK_COUNT} subtasks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => handleExpand(todo.ID)}
|
||||
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)] px-2"
|
||||
>
|
||||
{expandedId === todo.ID ? "▼" : "▶"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(todo.ID)}
|
||||
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
{expandedId === todo.ID && (
|
||||
<div className="ml-8 mt-1 space-y-1">
|
||||
{subtasks.map((st) => (
|
||||
<div
|
||||
key={st.ID}
|
||||
className="flex items-center gap-3 bg-[var(--color-bg-card)] rounded-lg px-3 py-2 border border-[var(--color-border)]"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleToggleSubtask(st)}
|
||||
className={`w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
|
||||
st.STATUS === "DONE"
|
||||
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
|
||||
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||
}`}
|
||||
>
|
||||
{st.STATUS === "DONE" && (
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className={`text-sm flex-1 ${st.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
|
||||
{st.TITLE}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Add subtask */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtask}
|
||||
onChange={(e) => setNewSubtask(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddSubtask()}
|
||||
placeholder="서브태스크 추가..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddSubtask}
|
||||
disabled={!newSubtask.trim()}
|
||||
className="px-3 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-xs transition-colors"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { api, LoginResponse } from "./api";
|
||||
import { useAuth } from "./auth-context";
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { AxiosRequestConfig, AxiosError } from "axios";
|
||||
|
||||
export function useApi() {
|
||||
const { setAccessToken, logout } = useAuth();
|
||||
@@ -13,8 +13,9 @@ export function useApi() {
|
||||
try {
|
||||
const response = await api.request<T>(config);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401) {
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (axiosError.response?.status === 401) {
|
||||
try {
|
||||
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
|
||||
setAccessToken(refreshRes.data.accessToken);
|
||||
|
||||
Reference in New Issue
Block a user