vignette/apps/web/src/pages/Settings.tsx
2026-06-27 17:51:54 +09:00

885 lines
33 KiB
TypeScript

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<string, string> = {
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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
aria-hidden="true"
>
<polygon points="6 4 19 12 6 20" />
</svg>
);
}
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 (
<div className="vg-set__state" role="status" data-testid={testId}>
<span className="vg-set__state-dot" aria-hidden="true" />
<span>{children}</span>
</div>
);
}
/**
* 로딩 스켈레톤 — 회색 박스 반복 대신 섹션 형태(라벨/행 윤곽)를 미리 그려
* 미완성·오류로 오인되지 않게 한다. 로딩 testId·role 은 그대로 유지한다.
*/
function SettingsSkeleton({
children,
testId,
variant = "lines",
}: {
children: string;
testId: string;
variant?: "lines" | "rows" | "voice";
}) {
return (
<div
className={`vg-set__skel vg-set__skel--${variant}`}
role="status"
aria-live="polite"
data-testid={testId}
>
<span className="vg-set__skel-sr">{children}</span>
{variant === "rows" ? (
<>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
</>
) : variant === "voice" ? (
<>
<span className="vg-set__skel-card" aria-hidden="true" />
<span className="vg-set__skel-card" aria-hidden="true" />
</>
) : (
<>
<span className="vg-set__skel-line vg-set__skel-line--label" aria-hidden="true" />
<span className="vg-set__skel-line" aria-hidden="true" />
<span className="vg-set__skel-line vg-set__skel-line--short" aria-hidden="true" />
</>
)}
</div>
);
}
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<UserProfileResponse | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [affiliation, setAffiliation] = useState("");
const [preferences, setPreferences] = useState<UserPreferencesResponse | null>(null);
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
const [profileReady, setProfileReady] = useState(false);
const [preferencesReady, setPreferencesReady] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savedKey, setSavedKey] = useState<string | null>(null);
const savedTimer = useRef<number | null>(null);
const displayNameInputRef = useRef<HTMLInputElement | null>(null);
const affiliationInputRef = useRef<HTMLInputElement | null>(null);
const engineUrlInputRef = useRef<HTMLInputElement | null>(null);
const engineModelInputRef = useRef<HTMLInputElement | null>(null);
const displayNameValueRef = useRef(user?.name ?? "");
const affiliationValueRef = useRef("");
const displayNameDirtyRef = useRef(false);
const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(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<AdminEngineConfigResponse>) => {
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<HTMLElement>("#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 (
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
{error ? (
<div className="vg-set__callout vg-set__callout--warn" role="alert">
<span className="vg-set__callout-ico">
<Icon name="alert" size={18} />
</span>
<span className="vg-set__callout-text">{error}</span>
</div>
) : null}
<div className="vg-set" aria-busy={loading}>
<aside className="vg-set__rail">
<div className="vg-set__rail-title">
<span className="vg-set__rail-mark" aria-hidden="true">
<Icon name="settings" size={24} strokeWidth={2} />
</span>
<div>
<h1></h1>
<p> </p>
</div>
</div>
<div className="vg-set__rail-card" aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
</div>
<div className="vg-set__rail-copy">
<div className="vg-set__rail-name">{accountName}</div>
<div className="vg-set__rail-email">{accountEmail}</div>
<div className="vg-set__rail-badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div>
</div>
</div>
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SECTIONS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={
"vg-set__nav-item" + (activeSection === item.id ? " is-active" : "")
}
aria-current={activeSection === item.id ? "location" : undefined}
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={17} />
<span>{item.label}</span>
</button>
))}
</nav>
<div className="vg-set__rail-status" aria-label="설정 상태">
<span className="vg-set__pill">{roleLabel(role)}</span>
{isAdmin ? (
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
AI {healthStatusLabel(engineService?.status)}
</span>
) : null}
</div>
</aside>
<div className="vg-set__forms">
<Panel id="set-account" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
<div className="vg-set__profile">
<div className="vg-set__avatar" aria-hidden="true">
{initials}
</div>
<div className="vg-set__profile-meta">
<div className="n">{accountName}</div>
<div className="e">{accountEmail}</div>
<div className="badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div>
</div>
</div>
<div className="vg-set__field-grid vg-set__field-grid--account">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={displayNameInputRef}
value={displayName}
onChange={(event) => {
displayNameDirtyRef.current = true;
displayNameValueRef.current = event.target.value;
setDisplayName(event.target.value);
}}
placeholder="표시할 이름을 입력하세요"
aria-label="표시 이름"
/>
</Field>
</div>
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
value={profile?.email ?? ""}
disabled
placeholder="로그인 계정 이메일"
aria-label="이메일"
/>
</Field>
</div>
<div className="vg-set__field vg-set__field--wide">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={affiliationInputRef}
value={affiliation}
onChange={(event) => {
affiliationDirtyRef.current = true;
affiliationValueRef.current = event.target.value;
setAffiliation(event.target.value);
}}
placeholder="소속을 입력하세요"
aria-label="소속"
/>
</Field>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "account" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveProfile()}
disabled={loading || !profileReady}
>
</Button>
</div>
</Panel>
{isAdmin ? (
<Panel
id="set-engine"
className="vg-set__group vg-set__group--wide vg-set__group--engine"
>
<div className="vg-set__group-head vg-set__group-head--split">
<div>
<Kicker></Kicker>
<div className="vg-set__group-title vg-set__group-title--kicker">
AI
</div>
<div className="vg-set__group-desc">
.
</div>
</div>
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
<span className="vg-set__ops-dot" aria-hidden="true" />
<div>
<b> {healthStatusLabel(engineService?.status)}</b>
<span>
{engineService
? `${engineService.detail} · ${engineService.metric}`
: "운영 상태를 확인하는 중입니다."}
</span>
</div>
</div>
</div>
{engineConfig ? (
<>
<div className="vg-set__engine-grid">
<div className="vg-set__control-block">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__seg" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={engineConfig.engine_mode === mode}
data-engine-mode={mode}
className={
"vg-set__seg-btn" +
(engineConfig.engine_mode === mode ? " is-active" : "")
}
onClick={() => updateEngineConfig({ engine_mode: mode })}
>
{ENGINE_MODE_LABEL[mode] ?? mode}
</button>
))}
</div>
</div>
<div className="vg-set__field-grid vg-set__field-grid--engine">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
</Field>
</div>
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineModelInputRef}
value={engineConfig.model}
onChange={(event) =>
updateEngineConfig({ model: event.target.value })
}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
</div>
</div>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "engine" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={loading || !engineHasRequiredFields}
>
</Button>
</div>
</>
) : (
<SettingsSkeleton testId="settings-engine-loading" variant="lines">
AI .
</SettingsSkeleton>
)}
</Panel>
) : null}
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
<div className="vg-set__opt">
<div className="vg-set__opt-text">
<div className="l"> </div>
<div className="h"> .</div>
</div>
<span className="vg-set__toggle-line">
<Icon name={dark ? "moon" : "sun"} size={16} />
<Toggle checked={dark} onChange={setDark} label="다크 모드" />
</span>
</div>
<div className="vg-set__foot">
{savedKey === "appearance" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("appearance")}
disabled={loading || !preferencesReady}
>
</Button>
</div>
</>
) : (
<SettingsSkeleton testId="settings-preferences-loading" variant="rows">
.
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-voice" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
voicePresets.length > 0 ? (
<>
<div className="vg-set__voicelist" role="radiogroup" aria-label="음성 프리셋">
{voicePresets.map((preset) => {
const selected = preferences.voice_preset_id === preset.id;
return (
<button
key={preset.id}
type="button"
role="radio"
aria-checked={selected}
className={"vg-set__voice" + (selected ? " is-on" : "")}
onClick={() =>
setPreferences((cur) =>
cur ? { ...cur, voice_preset_id: preset.id } : cur,
)
}
>
<span className="vg-set__voice-rad" aria-hidden="true" />
<span className="vg-set__voice-info">
<span className="vn">
{preset.name} · {preset.voice_id}
</span>
<span className="vd">
{preset.desc} · {preset.persona_hint}
</span>
</span>
<span
className="vg-set__voice-play"
role="presentation"
aria-hidden="true"
>
<SpeakerIcon />
</span>
</button>
);
})}
</div>
<div className="vg-set__range-row">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__range-control">
<input
type="range"
min={0.8}
max={1.2}
step={0.02}
value={preferences.voice_rate}
onChange={(event) =>
setPreferences((cur) =>
cur
? {
...cur,
voice_rate: Number(event.target.value),
}
: cur,
)
}
aria-label="말하기 속도"
/>
<div className="vg-set__meta vg-set__meta--range">
<span>0.8x</span>
<span>{preferences.voice_rate.toFixed(2)}x</span>
<span>1.2x</span>
</div>
</div>
</div>
<div className="vg-set__foot">
{savedKey === "voice" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("voice")}
disabled={loading || !preferencesReady}
>
</Button>
</div>
</>
) : (
<SettingsLoadingState testId="settings-voice-empty">
.
</SettingsLoadingState>
)
) : (
<SettingsSkeleton testId="settings-voice-loading" variant="voice">
.
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-notify" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
<div className="vg-set__opt-grid">
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>
</div>
<Toggle
checked={Boolean(
notificationPreferences[item.id as keyof NotificationPreferences],
)}
onChange={(next) =>
updateNotification(item.id as keyof NotificationPreferences, next)
}
label={item.label}
/>
</div>
))}
</div>
<div className="vg-set__foot">
{savedKey === "notify" ? (
<span className="vg-set__saved">
<Icon name="check" size={15} />
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("notify")}
disabled={loading || !preferencesReady}
>
</Button>
</div>
</>
) : (
<SettingsSkeleton testId="settings-notify-loading" variant="rows">
.
</SettingsSkeleton>
)}
</Panel>
</div>
</div>
</AppShell>
);
}