feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
645
apps/web/src/pages/Settings.tsx
Normal file
645
apps/web/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
/* =====================================================================
|
||||
Settings (/settings) — 3역할 공통 진입, 역할별 가시 섹션 분기.
|
||||
DEPLOYMENT.md §2 '3번 경로 = 관리자 UI 런타임 엔진 전환' 구현:
|
||||
· 엔진 모드 claude_p ↔ messages_api 세그먼트 토글 (관리자만)
|
||||
· ENGINE_URL / 모델 입력 (관리자만)
|
||||
+ 음성/보이스 프리셋(페르소나별), 테마 라이트/다크, 알림 토글.
|
||||
디자인 철칙: border-left·이모지·카드덤프·순흑백·과한 라운드 금지.
|
||||
강조는 weight/tint/kicker/dot. 모든 인터랙션은 React state 로 실동작.
|
||||
데이터는 현실적인 더미(mock)로 채움 — 빈 껍데기 금지.
|
||||
===================================================================== */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Icon,
|
||||
Input,
|
||||
Kicker,
|
||||
Panel,
|
||||
SectionHead,
|
||||
type IconName,
|
||||
} from "../components/ui";
|
||||
import { useAuth, roleLabel, type Role } from "../lib/auth";
|
||||
import { Toggle } from "./settings/Toggle";
|
||||
import { useTheme } from "./settings/useTheme";
|
||||
import "./settings/settings.css";
|
||||
|
||||
/* =====================================================================
|
||||
타입 + mock 데이터 (전부 mock 임을 주석으로 명시. 추후 API 로 교체)
|
||||
===================================================================== */
|
||||
|
||||
type EngineMode = "claude_p" | "messages_api";
|
||||
|
||||
/** 엔진 모델 후보 — 현재 Claude 모델 ID (engine_gateway 기본은 Opus 4.8). */
|
||||
const ENGINE_MODELS: { id: string; label: string }[] = [
|
||||
{ id: "claude-opus-4-8", label: "Claude Opus 4.8 · 기본(권장)" },
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 · 빠름/저비용" },
|
||||
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5 · 최저비용" },
|
||||
];
|
||||
|
||||
/** 페르소나별 음성 프리셋 (mock). 실제로는 taxonomy/persona API 에서 온다. */
|
||||
interface VoicePreset {
|
||||
id: string;
|
||||
/** voice_id (엔진 TTS 식별자) */
|
||||
voiceId: string;
|
||||
/** 사람친화 표시명 */
|
||||
name: string;
|
||||
desc: string;
|
||||
/** 어떤 페르소나 기본 음성인지 (mock 연결) */
|
||||
personaHint: string;
|
||||
}
|
||||
const VOICE_PRESETS: VoicePreset[] = [
|
||||
{
|
||||
id: "v-soft",
|
||||
voiceId: "soft-young-fem",
|
||||
name: "서린",
|
||||
desc: "부드럽고 높은 톤 · 청소년 내담자에 적합",
|
||||
personaHint: "서연 · 17세",
|
||||
},
|
||||
{
|
||||
id: "v-calm",
|
||||
voiceId: "calm-adult-fem",
|
||||
name: "하경",
|
||||
desc: "차분한 여성 · 표준 성인 톤",
|
||||
personaHint: "기본 성인",
|
||||
},
|
||||
{
|
||||
id: "v-steady",
|
||||
voiceId: "steady-adult-male",
|
||||
name: "도현",
|
||||
desc: "낮고 안정적 · 남성 내담자",
|
||||
personaHint: "강현우 · 34세",
|
||||
},
|
||||
{
|
||||
id: "v-warm",
|
||||
voiceId: "warm-senior",
|
||||
name: "정인",
|
||||
desc: "느리고 따뜻한 · 노년 내담자",
|
||||
personaHint: "노년 내담자",
|
||||
},
|
||||
];
|
||||
|
||||
/** 알림 항목 (mock 기본값). 역할별로 일부만 노출. */
|
||||
interface NotifyItem {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
/** 이 항목을 보여줄 역할 (없으면 전 역할) */
|
||||
roles?: Role[];
|
||||
}
|
||||
const NOTIFY_ITEMS: NotifyItem[] = [
|
||||
{
|
||||
id: "session_done",
|
||||
label: "세션 완료 알림",
|
||||
hint: "상담 연습 회기를 마치면 결과 요약을 알립니다.",
|
||||
},
|
||||
{
|
||||
id: "safety_signal",
|
||||
label: "안전 신호 감지",
|
||||
hint: "세션 중 위기 신호가 포착되면 즉시 알립니다.",
|
||||
},
|
||||
{
|
||||
id: "learner_progress",
|
||||
label: "담당 학습자 진척",
|
||||
hint: "지도 중인 학습자가 회기를 마치면 알립니다.",
|
||||
roles: ["teacher", "admin"],
|
||||
},
|
||||
{
|
||||
id: "product_news",
|
||||
label: "제품 소식",
|
||||
hint: "기능 업데이트·연구 노트.",
|
||||
},
|
||||
];
|
||||
|
||||
/** 섹션 내비 항목 — 역할별 표시 분기. */
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
/** admin 전용이면 true */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
작은 로컬 아이콘 (Icon 세트에 없는 speaker 등은 inline SVG, currentColor)
|
||||
===================================================================== */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
페이지
|
||||
===================================================================== */
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const role: Role = user?.role ?? "learner";
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
// 표시 이름·이메일·소속 (mock 초기값 — auth user 가 있으면 일부 사용)
|
||||
const [displayName, setDisplayName] = useState(user?.name ?? "사용자");
|
||||
const [email, setEmail] = useState(`${user?.userId ?? "user"}@hanshin.ac.kr`);
|
||||
const [affiliation, setAffiliation] = useState("한신대학교 심리상담학과");
|
||||
|
||||
// 테마 (Topbar 토글과 동일 키/속성 공유 → 충돌 없음)
|
||||
const [dark, setDark] = useTheme();
|
||||
|
||||
// 엔진 (관리자 전용). 기본은 .env 운영값을 미러한 mock.
|
||||
const [engineMode, setEngineMode] = useState<EngineMode>("claude_p");
|
||||
const [engineUrl, setEngineUrl] = useState(
|
||||
"http://host.docker.internal:9099",
|
||||
);
|
||||
const [engineModel, setEngineModel] = useState(ENGINE_MODELS[0].id);
|
||||
|
||||
// 음성: 선택된 프리셋 + 속도(0.8~1.2, 표준 1.0)
|
||||
const [voiceId, setVoiceId] = useState(VOICE_PRESETS[0].id);
|
||||
const [voiceRate, setVoiceRate] = useState(1.0);
|
||||
|
||||
// 알림 토글 상태 (mock 기본값)
|
||||
const [notify, setNotify] = useState<Record<string, boolean>>({
|
||||
session_done: true,
|
||||
safety_signal: true,
|
||||
learner_progress: false,
|
||||
product_news: false,
|
||||
});
|
||||
|
||||
// 저장 피드백 (어떤 그룹이 방금 저장됐는지)
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||||
const savedTimer = useRef<number | null>(null);
|
||||
const flashSaved = (key: string) => {
|
||||
setSavedKey(key);
|
||||
if (savedTimer.current) window.clearTimeout(savedTimer.current);
|
||||
savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400);
|
||||
};
|
||||
|
||||
// 역할별 노출 알림 항목
|
||||
const visibleNotify = useMemo(
|
||||
() => NOTIFY_ITEMS.filter((n) => !n.roles || n.roles.includes(role)),
|
||||
[role],
|
||||
);
|
||||
|
||||
// 섹션 내비 (admin 전용 '엔진' 섹션 분기)
|
||||
const navItems: NavItem[] = useMemo(() => {
|
||||
const base: NavItem[] = [
|
||||
{ id: "account", label: "계정", icon: "users" },
|
||||
{ id: "appearance", label: "테마·표시", icon: "settings" },
|
||||
{ id: "voice", label: "음성", icon: "mic" },
|
||||
{ id: "notify", label: "알림", icon: "info" },
|
||||
];
|
||||
if (isAdmin) {
|
||||
base.splice(1, 0, { id: "engine", label: "엔진", icon: "shield", adminOnly: true });
|
||||
}
|
||||
return base;
|
||||
}, [isAdmin]);
|
||||
|
||||
const goSection = (id: string) => {
|
||||
document
|
||||
.getElementById(`set-${id}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const initials = (user?.name ?? "사용자").trim().slice(0, 1);
|
||||
|
||||
return (
|
||||
<AppShell contextLabel="설정">
|
||||
<div style={{ marginBottom: "var(--sp-6)" }}>
|
||||
<SectionHead
|
||||
kicker="설정"
|
||||
title="계정과 시뮬레이션 환경을 관리합니다"
|
||||
desc={
|
||||
isAdmin
|
||||
? "프로필, 런타임 엔진, 음성 프리셋, 테마, 알림을 한곳에서 조정하세요."
|
||||
: "프로필, 음성 프리셋, 테마, 알림을 한곳에서 조정하세요."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vg-set">
|
||||
{/* ── 좌측 섹션 내비 ── */}
|
||||
<nav className="vg-set__nav" aria-label="설정 섹션">
|
||||
<div className="vg-set__nav-kicker">SETTINGS</div>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="vg-set__nav-item"
|
||||
onClick={() => goSection(item.id)}
|
||||
>
|
||||
<Icon name={item.icon} size={18} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── 우측 폼 ── */}
|
||||
<div className="vg-set__forms">
|
||||
{/* (1) 계정 — 전 역할 */}
|
||||
<Panel id="set-account" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">계정</div>
|
||||
<div className="vg-set__group-desc">
|
||||
프로필과 역할, 로그인 정보를 관리합니다.
|
||||
</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">{displayName}</div>
|
||||
<div className="e">{email}</div>
|
||||
<div className="badges">
|
||||
<Badge tone="accent">{roleLabel(role)}</Badge>
|
||||
<Badge tone="neutral">{affiliation}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">표시 이름</span>
|
||||
<span className="h">학습자·동료에게 보이는 이름입니다.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field>
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
aria-label="표시 이름"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">이메일</span>
|
||||
<span className="h">로그인 및 알림 수신 주소.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-label="이메일"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">소속</span>
|
||||
<span className="h">학과·기관 표기.</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field>
|
||||
<Input
|
||||
value={affiliation}
|
||||
onChange={(e) => setAffiliation(e.target.value)}
|
||||
aria-label="소속"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">역할</span>
|
||||
<span className="h">
|
||||
권한 범위가 달라집니다. 변경은 관리자 승인이 필요합니다.
|
||||
</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<span style={{ display: "inline-flex" }}>
|
||||
<Badge tone="accent">{roleLabel(role)}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__foot">
|
||||
{savedKey === "account" ? (
|
||||
<span className="vg-set__saved">
|
||||
<Icon name="check" size={15} />
|
||||
저장됨
|
||||
</span>
|
||||
) : null}
|
||||
<Button variant="ghost">변경 취소</Button>
|
||||
<Button onClick={() => flashSaved("account")}>저장</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* (2) 엔진 — 관리자 전용 (DEPLOYMENT §2 3번 경로) */}
|
||||
{isAdmin ? (
|
||||
<Panel id="set-engine" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<Kicker>관리자 · 런타임</Kicker>
|
||||
<div
|
||||
className="vg-set__group-title"
|
||||
style={{ marginTop: "6px" }}
|
||||
>
|
||||
생성형 엔진
|
||||
</div>
|
||||
<div className="vg-set__group-desc">
|
||||
내담자 발화를 생성하는 엔진을 런타임에 전환합니다. 저장하면
|
||||
DB에 반영되어 재기동 없이 적용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__callout vg-set__callout--info">
|
||||
<span className="vg-set__callout-ico">
|
||||
<Icon name="info" size={18} />
|
||||
</span>
|
||||
<span className="vg-set__callout-text">
|
||||
<strong>claude_p</strong> 는 호스트의 로컬 claude -p
|
||||
게이트웨이를 사용합니다(네 PC 운영). 타호스트로 옮길 때는{" "}
|
||||
<strong>messages_api</strong> 로 전환하면 ENGINE_URL 없이{" "}
|
||||
ANTHROPIC_API_KEY 로 동작합니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">엔진 모드</span>
|
||||
<span className="h">
|
||||
어댑터 뒤의 엔진 위치를 결정합니다.
|
||||
</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<div
|
||||
className="vg-set__seg"
|
||||
role="radiogroup"
|
||||
aria-label="엔진 모드"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ v: "claude_p", t: "claude_p", sub: "로컬 게이트웨이" },
|
||||
{
|
||||
v: "messages_api",
|
||||
t: "messages_api",
|
||||
sub: "Anthropic API",
|
||||
},
|
||||
] as { v: EngineMode; t: string; sub: string }[]
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.v}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={engineMode === opt.v}
|
||||
className={
|
||||
"vg-set__seg-btn" +
|
||||
(engineMode === opt.v ? " is-active" : "")
|
||||
}
|
||||
onClick={() => setEngineMode(opt.v)}
|
||||
>
|
||||
{opt.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="h" style={{ fontSize: "var(--fs-xs)", color: "var(--text-muted)" }}>
|
||||
{engineMode === "claude_p"
|
||||
? "호스트의 claude -p 멀티턴 풀로 라우팅합니다."
|
||||
: "ANTHROPIC_API_KEY 로 직접 호출합니다 (ENGINE_URL 불필요)."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">ENGINE_URL</span>
|
||||
<span className="h">
|
||||
claude_p 모드에서 게이트웨이 주소.
|
||||
</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<Field
|
||||
hint={
|
||||
engineMode === "messages_api"
|
||||
? "messages_api 모드에서는 사용하지 않습니다."
|
||||
: "예: http://host.docker.internal:9099 또는 http://<엔진PC-IP>:9099"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={engineUrl}
|
||||
onChange={(e) => setEngineUrl(e.target.value)}
|
||||
disabled={engineMode === "messages_api"}
|
||||
placeholder="http://host.docker.internal:9099"
|
||||
aria-label="ENGINE_URL"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__row">
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">모델</span>
|
||||
<span className="h">
|
||||
내담자 발화 생성에 사용할 Claude 모델.
|
||||
</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<select
|
||||
className="vg-set__select"
|
||||
value={engineModel}
|
||||
onChange={(e) => setEngineModel(e.target.value)}
|
||||
aria-label="엔진 모델"
|
||||
>
|
||||
{ENGINE_MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="vg-set__meta">
|
||||
<span>
|
||||
현재 적용: <code>{engineMode}</code>
|
||||
</span>
|
||||
<span>
|
||||
모델: <code>{engineModel}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__foot">
|
||||
{savedKey === "engine" ? (
|
||||
<span className="vg-set__saved">
|
||||
<Icon name="check" size={15} />
|
||||
런타임에 반영됨
|
||||
</span>
|
||||
) : null}
|
||||
<Button variant="ghost">기본값 복원</Button>
|
||||
<Button onClick={() => flashSaved("engine")}>전환 적용</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{/* (3) 테마·표시 — 전 역할 */}
|
||||
<Panel id="set-appearance" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">테마·표시</div>
|
||||
<div className="vg-set__group-desc">
|
||||
화면 밝기와 표시 환경을 조정합니다. 설정은 이 기기에 저장됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__opt">
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">다크 모드</div>
|
||||
<div className="h">
|
||||
어두운 환경에서 눈의 피로를 줄입니다. 상단바 토글과 동기화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--sp-3)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<Icon name={dark ? "moon" : "sun"} size={16} />
|
||||
<Toggle
|
||||
checked={dark}
|
||||
onChange={setDark}
|
||||
label="다크 모드"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* (4) 음성 — 전 역할 (페르소나별 프리셋) */}
|
||||
<Panel id="set-voice" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">음성</div>
|
||||
<div className="vg-set__group-desc">
|
||||
시연·미리듣기 기본 목소리를 고릅니다. 각 프리셋은 페르소나
|
||||
연령대에 맞춰 매핑됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vg-set__voicelist"
|
||||
role="radiogroup"
|
||||
aria-label="음성 프리셋"
|
||||
>
|
||||
{VOICE_PRESETS.map((v) => {
|
||||
const on = voiceId === v.id;
|
||||
return (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={"vg-set__voice" + (on ? " is-on" : "")}
|
||||
onClick={() => setVoiceId(v.id)}
|
||||
>
|
||||
<span className="vg-set__voice-rad" aria-hidden="true" />
|
||||
<span className="vg-set__voice-info">
|
||||
<span className="vn">
|
||||
{v.name} · {v.voiceId}
|
||||
</span>
|
||||
<span className="vd">
|
||||
{v.desc} · {v.personaHint}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="vg-set__voice-play"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SpeakerIcon />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="vg-set__row"
|
||||
style={{ marginTop: "var(--sp-5)", borderTop: "1px solid var(--bg-surface-2)" }}
|
||||
>
|
||||
<div className="vg-set__row-label">
|
||||
<span className="l">말하기 속도</span>
|
||||
<span className="h">
|
||||
기본 발화 속도(rate). 1.0이 표준입니다.
|
||||
</span>
|
||||
</div>
|
||||
<div className="vg-set__row-field">
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
max={1.2}
|
||||
step={0.02}
|
||||
value={voiceRate}
|
||||
onChange={(e) => setVoiceRate(Number(e.target.value))}
|
||||
aria-label="말하기 속도"
|
||||
style={{ accentColor: "var(--accent)", width: "100%" }}
|
||||
/>
|
||||
<div className="vg-set__meta">
|
||||
<span>0.8×</span>
|
||||
<span style={{ color: "var(--accent-deep)", fontWeight: 600 }}>
|
||||
{voiceRate.toFixed(2)}×
|
||||
</span>
|
||||
<span>1.2×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__foot">
|
||||
{savedKey === "voice" ? (
|
||||
<span className="vg-set__saved">
|
||||
<Icon name="check" size={15} />
|
||||
저장됨
|
||||
</span>
|
||||
) : null}
|
||||
<Button variant="ghost">미리듣기</Button>
|
||||
<Button onClick={() => flashSaved("voice")}>저장</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* (5) 알림 — 전 역할 (일부 항목은 교수/관리자만) */}
|
||||
<Panel id="set-notify" className="vg-set__group">
|
||||
<div className="vg-set__group-head">
|
||||
<div className="vg-set__group-title">알림</div>
|
||||
<div className="vg-set__group-desc">
|
||||
언제, 무엇을 받을지 정합니다. 과한 알림은 끄는 편을 권합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleNotify.map((n) => (
|
||||
<div className="vg-set__opt" key={n.id}>
|
||||
<div className="vg-set__opt-text">
|
||||
<div className="l">{n.label}</div>
|
||||
<div className="h">{n.hint}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!notify[n.id]}
|
||||
onChange={(next) =>
|
||||
setNotify((prev) => ({ ...prev, [n.id]: next }))
|
||||
}
|
||||
label={n.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue