import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Field, Icon, Input, Kicker, Panel, type IconName, } from "../components/ui"; import { roleLabel, useAuth } from "../lib/auth"; import { adminApi, adminEngineApi, userApi, type AdminHealthResponse, type AdminEngineConfigResponse, type NotificationPreferences, type UserPreferencesResponse, type UserProfileResponse, type VoicePresetResponse, } from "../lib/api"; import { Toggle } from "./settings/Toggle"; import { useTheme } from "./settings/useTheme"; import "./settings/settings.css"; interface NavItem { id: string; label: string; icon: IconName; adminOnly?: boolean; } const ENGINE_MODE_LABEL: Record = { claude_cli: "Claude CLI 게이트웨이", messages_api: "클라우드 연결", claude_api: "Anthropic API", openai: "OpenAI 호환", solar: "Solar", }; const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const; function SpeakerIcon({ size = 15 }: { size?: number }) { return ( ); } function healthStatusLabel(status: string | undefined): string { if (status === "ok") return "정상"; if (status === "degraded") return "제한 운영"; if (status === "down") return "중단"; return "확인 중"; } function SettingsLoadingState({ children, testId, }: { children: string; testId: string; }) { return (
); } /** * 로딩 스켈레톤 — 회색 박스 반복 대신 섹션 형태(라벨/행 윤곽)를 미리 그려 * 미완성·오류로 오인되지 않게 한다. 로딩 testId·role 은 그대로 유지한다. */ function SettingsSkeleton({ children, testId, variant = "lines", }: { children: string; testId: string; variant?: "lines" | "rows" | "voice"; }) { return (
{children} {variant === "rows" ? ( <>
); } const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { session_done: true, safety_signal: true, learner_progress: true, product_news: false, }; function completeNotificationPreferences( preferences?: NotificationPreferences | null, ): NotificationPreferences { return { ...DEFAULT_NOTIFICATION_PREFERENCES, ...preferences, }; } export default function Settings() { const { user } = useAuth(); const role = user?.role ?? "learner"; const isAdmin = role === "admin"; const [dark, setDark] = useTheme(); const [activeSection, setActiveSection] = useState("account"); const [profile, setProfile] = useState(null); const [displayName, setDisplayName] = useState(user?.name ?? ""); const [affiliation, setAffiliation] = useState(""); const [preferences, setPreferences] = useState(null); const [voicePresets, setVoicePresets] = useState([]); const [engineConfig, setEngineConfig] = useState(null); const [adminHealth, setAdminHealth] = useState(null); const [profileReady, setProfileReady] = useState(false); const [preferencesReady, setPreferencesReady] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [savedKey, setSavedKey] = useState(null); const savedTimer = useRef(null); const displayNameInputRef = useRef(null); const affiliationInputRef = useRef(null); const engineUrlInputRef = useRef(null); const engineModelInputRef = useRef(null); const displayNameValueRef = useRef(user?.name ?? ""); const affiliationValueRef = useRef(""); const displayNameDirtyRef = useRef(false); const affiliationDirtyRef = useRef(false); const engineConfigRef = useRef(null); useEffect(() => { document.body.setAttribute("data-page", "settings"); return () => { document.body.removeAttribute("data-page"); }; }, []); const flashSaved = (key: string) => { setSavedKey(key); if (savedTimer.current) window.clearTimeout(savedTimer.current); savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400); }; const loadSettings = useCallback(async () => { setLoading(true); setError(null); setProfileReady(false); setPreferencesReady(false); setPreferences(null); setVoicePresets([]); setEngineConfig(null); engineConfigRef.current = null; try { const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([ userApi.me(), userApi.preferences(), userApi.voicePresets(), isAdmin ? adminEngineApi.get() : Promise.resolve(null), ]); setProfile(nextProfile); setProfileReady(true); if (!displayNameDirtyRef.current) { setDisplayName(nextProfile.display_name); displayNameValueRef.current = nextProfile.display_name; } if (!affiliationDirtyRef.current) { setAffiliation(nextProfile.affiliation); affiliationValueRef.current = nextProfile.affiliation; } setPreferences(nextPrefs); setPreferencesReady(true); setDark(nextPrefs.theme === "dark"); setVoicePresets(presets); if (nextEngine) { engineConfigRef.current = nextEngine; setEngineConfig(nextEngine); } if (isAdmin) { void adminApi.health() .then(setAdminHealth) .catch(() => setAdminHealth(null)); } } catch (err) { setError(err instanceof Error ? err.message : "설정을 불러오지 못했습니다."); } finally { setLoading(false); } }, [isAdmin, setDark]); useEffect(() => { void loadSettings(); }, [loadSettings]); const navItems: NavItem[] = useMemo(() => { const base: NavItem[] = [ { id: "account", label: "계정", icon: "users" }, { id: "appearance", label: "테마", icon: "settings" }, { id: "notify", label: "알림", icon: "info" }, { id: "voice", label: "음성", icon: "mic" }, ]; if (isAdmin) { base.splice(1, 0, { id: "engine", label: "AI 운영", icon: "shield", adminOnly: true }); } return base; }, [isAdmin]); useEffect(() => { const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; if (visible?.target.id) { setActiveSection(visible.target.id.replace(/^set-/, "")); } }, { rootMargin: "-18% 0px -62% 0px", threshold: [0.2, 0.55], }, ); for (const item of navItems) { const section = document.getElementById(`set-${item.id}`); if (section) observer.observe(section); } return () => observer.disconnect(); }, [navItems]); const visibleNotifications = useMemo( () => [ { id: "session_done", label: "세션 완료", hint: "학습 회기가 종료되면 알려줍니다.", }, { id: "safety_signal", label: "안전 신호", hint: "위기 신호가 감지되면 우선 알림을 표시합니다.", }, { id: "learner_progress", label: "담당 학습자 진행", hint: "교수자와 관리자에게만 표시됩니다.", roles: ["teacher", "admin"], }, { id: "product_news", label: "제품 소식", hint: "기능 변경과 연구 운영 공지를 받습니다.", }, ].filter((item) => !item.roles || item.roles.includes(role)), [role], ); const goSection = (id: string) => { setActiveSection(id); document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); }; const updateEngineConfig = (patch: Partial) => { if (!engineConfigRef.current) return; engineConfigRef.current = { ...engineConfigRef.current, ...patch }; setEngineConfig((cur) => (cur ? { ...cur, ...patch } : cur)); }; const saveProfile = async () => { if (!profileReady) return; const next = await userApi.updateMe({ display_name: displayNameValueRef.current, affiliation: affiliationValueRef.current, }); setProfile(next); setDisplayName(next.display_name); setAffiliation(next.affiliation); displayNameValueRef.current = next.display_name; affiliationValueRef.current = next.affiliation; displayNameDirtyRef.current = false; affiliationDirtyRef.current = false; flashSaved("account"); }; const savePreferences = async (key: string) => { if (!preferencesReady || !preferences) return; const notifications = completeNotificationPreferences(preferences.notifications); const next = await userApi.updatePreferences({ theme: dark ? "dark" : "light", voice_preset_id: preferences.voice_preset_id, voice_rate: preferences.voice_rate, notifications, }); setPreferences(next); flashSaved(key); }; const saveEngine = async () => { const currentEngine = engineConfigRef.current; if (!currentEngine) return; const selectedMode = document.querySelector("#set-engine [data-engine-mode][aria-checked='true']") ?.dataset.engineMode ?? currentEngine.engine_mode; const next = await adminEngineApi.update({ engine_mode: selectedMode, engine_url: engineUrlInputRef.current?.value ?? currentEngine.engine_url, model: engineModelInputRef.current?.value ?? currentEngine.model, }); engineConfigRef.current = next; setEngineConfig(next); try { setAdminHealth(await adminApi.health()); } catch { setAdminHealth(null); } flashSaved("engine"); }; const updateNotification = (key: keyof NotificationPreferences, value: boolean) => { setPreferences((cur) => cur ? ({ ...cur, notifications: { ...completeNotificationPreferences(cur.notifications), [key]: value, }, }) : cur); }; const accountName = displayName || profile?.email || user?.name || user?.email || "사용자"; const accountEmail = profile?.email ?? user?.email ?? ""; const initials = accountName.trim().slice(0, 1).toUpperCase(); const engineHasRequiredFields = engineConfig ? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0 : false; const engineStorageLabel = !engineConfig ? "확인 중" : engineConfig.durable ? "DB 저장" : "런타임 적용"; const engineService = adminHealth?.services.find((service) => service.key === "engine"); const engineServiceStatus = engineService?.status ?? "degraded"; const notificationPreferences = completeNotificationPreferences(preferences?.notifications); return ( {error ? (
{error}
) : null}
계정
로그인 계정과 표시 정보를 관리합니다.
{accountName}
{accountEmail}
{roleLabel(role)} {affiliation ? {affiliation} : null}
표시 이름 교수자와 운영자가 확인하는 이름입니다.
{ displayNameDirtyRef.current = true; displayNameValueRef.current = event.target.value; setDisplayName(event.target.value); }} placeholder="표시할 이름을 입력하세요" aria-label="표시 이름" />
이메일 로그인으로 확인된 주소입니다.
소속 강의나 연구 운영에서 표시할 소속입니다.
{ affiliationDirtyRef.current = true; affiliationValueRef.current = event.target.value; setAffiliation(event.target.value); }} placeholder="소속을 입력하세요" aria-label="소속" />
{savedKey === "account" ? ( 저장됨 ) : null}
{isAdmin ? (
관리자
AI 운영
상담 응답 생성에 사용할 운영 연결과 기본 모델을 관리합니다.
{engineConfig ? ( <>
운영 방식 응답 생성 연결 방식을 선택합니다.
{ENGINE_MODES.map((mode) => ( ))}
연결 주소 응답 생성 서비스의 연결 주소입니다.
updateEngineConfig({ engine_url: event.target.value }) } placeholder="운영 연결 주소를 입력하세요" aria-label="AI 연결 주소" />
모델 응답 생성에 사용할 기본 모델 이름입니다.
updateEngineConfig({ model: event.target.value }) } placeholder="기본 모델 이름을 입력하세요" aria-label="모델" />
최근 변경자: {engineConfig.updated_by ?? "-"} 저장 상태: {engineStorageLabel}
{savedKey === "engine" ? ( 저장됨 ) : null}
) : ( 서버 AI 운영 설정을 불러오는 중입니다. )}
) : null}
테마
화면 밝기를 설정합니다.
{preferences ? ( <>
다크 모드
현재 기기와 브라우저에 적용됩니다.
{savedKey === "appearance" ? ( 저장됨 ) : null}
) : ( 서버 환경 설정을 불러오는 중입니다. )}
음성
상담 연습에 사용할 음성 프리셋을 선택합니다.
{preferences ? ( voicePresets.length > 0 ? ( <>
{voicePresets.map((preset) => { const selected = preferences.voice_preset_id === preset.id; return ( ); })}
말하기 속도 기본 발화 속도입니다.
setPreferences((cur) => cur ? { ...cur, voice_rate: Number(event.target.value), } : cur, ) } aria-label="말하기 속도" />
0.8x {preferences.voice_rate.toFixed(2)}x 1.2x
{savedKey === "voice" ? ( 저장됨 ) : null}
) : ( 서버에 등록된 음성 프리셋이 없습니다. ) ) : ( 서버 음성 설정을 불러오는 중입니다. )}
알림
역할에 맞는 알림만 표시됩니다.
{preferences ? ( <>
{visibleNotifications.map((item) => (
{item.label}
{item.hint}
updateNotification(item.id as keyof NotificationPreferences, next) } label={item.label} />
))}
{savedKey === "notify" ? ( 저장됨 ) : null}
) : ( 서버 알림 설정을 불러오는 중입니다. )}
); }