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:
2026-04-12 23:48:38 +00:00
parent 4cde775809
commit f9f710ec90
12 changed files with 434 additions and 73 deletions

View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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() {