Add English level settings, improve content structuring and rendering
- Add english_level column to users table (CEFR with TOEIC mapping) - Add UserController (GET/PATCH /api/users/me) and Settings page - Enhance structuring prompts: sequential TOC, no summary sections, no content overlap, English expression extraction by CEFR level - Remove sub-TOC analysis (caused content repetition), use simple per-section generation with truncation detection and continuation - Fix CLOB truncation: explicit Clob-to-String conversion in repository - Replace regex-based markdown rendering with react-markdown - Add wallet renewal procedure to troubleshooting docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
sundol-frontend/package-lock.json
generated
2
sundol-frontend/package-lock.json
generated
@@ -13,7 +13,7 @@
|
||||
"next": "^15.3.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-markdown": "^9.1.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,23 +9,23 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.3.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"zustand": "^5.0.3",
|
||||
"react-markdown": "^9.0.3",
|
||||
"lucide-react": "^0.469.0"
|
||||
"react-markdown": "^9.1.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.3.1",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 Category {
|
||||
ID: string;
|
||||
@@ -306,19 +307,25 @@ export default function KnowledgeDetailPage() {
|
||||
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
|
||||
</button>
|
||||
{showStructured && (
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] prose prose-invert max-w-none">
|
||||
<div
|
||||
className="text-sm leading-relaxed whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.STRUCTURED_CONTENT
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-base font-bold mt-4 mb-2 text-[var(--color-text)]">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-lg font-bold mt-5 mb-2 text-[var(--color-text)]">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold mt-6 mb-3 text-[var(--color-text)]">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
}}
|
||||
/>
|
||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] max-w-none">
|
||||
<div className="structured-content 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>,
|
||||
code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>,
|
||||
}}
|
||||
>
|
||||
{item.STRUCTURED_CONTENT}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
123
sundol-frontend/src/app/settings/page.tsx
Normal file
123
sundol-frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"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 UserProfile {
|
||||
ID: string;
|
||||
EMAIL: string;
|
||||
DISPLAY_NAME: string;
|
||||
AVATAR_URL: string;
|
||||
ENGLISH_LEVEL: string;
|
||||
}
|
||||
|
||||
const ENGLISH_LEVELS = [
|
||||
{ value: "A1", label: "A1 - 입문 (TOEIC 120~225)" },
|
||||
{ value: "A2", label: "A2 - 초급 (TOEIC 225~550, 기초 회화)" },
|
||||
{ value: "B1", label: "B1 - 중급 (TOEIC 550~785, 일상 의사소통)" },
|
||||
{ value: "B2", label: "B2 - 중상급 (TOEIC 785~945, 업무 영어)" },
|
||||
{ value: "C1", label: "C1 - 고급 (TOEIC 945~990, 유창함)" },
|
||||
{ value: "C2", label: "C2 - 원어민 수준" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { request } = useApi();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await request<UserProfile>({ method: "GET", url: "/api/users/me" });
|
||||
setProfile(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load profile:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleEnglishLevelChange = async (newLevel: string) => {
|
||||
if (!profile) return;
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
try {
|
||||
const updated = await request<UserProfile>({
|
||||
method: "PATCH",
|
||||
url: "/api/users/me",
|
||||
data: { englishLevel: newLevel },
|
||||
});
|
||||
setProfile(updated);
|
||||
setSavedMsg("저장되었습니다");
|
||||
setTimeout(() => setSavedMsg(""), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to update level:", err);
|
||||
alert("저장에 실패했습니다");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<NavBar />
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">설정</h1>
|
||||
|
||||
{/* 프로필 정보 */}
|
||||
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">프로필</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-24 text-[var(--color-text-muted)]">이름</span>
|
||||
<span>{profile?.DISPLAY_NAME || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-[var(--color-text-muted)]">이메일</span>
|
||||
<span>{profile?.EMAIL || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 영어 학습 수준 */}
|
||||
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold mb-2">영어 학습 수준</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||||
영어 컨텐츠를 정리할 때 추출할 학습 표현의 난이도를 결정합니다.
|
||||
</p>
|
||||
<select
|
||||
value={profile?.ENGLISH_LEVEL || "B2"}
|
||||
onChange={(e) => handleEnglishLevelChange(e.target.value)}
|
||||
disabled={saving}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{ENGLISH_LEVELS.map((level) => (
|
||||
<option key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{savedMsg && (
|
||||
<p className="text-sm text-green-400 mt-2">{savedMsg}</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const navItems = [
|
||||
{ href: "/study", label: "Study" },
|
||||
{ href: "/todos", label: "Todos" },
|
||||
{ href: "/habits", label: "Habits" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export default function NavBar() {
|
||||
|
||||
Reference in New Issue
Block a user