음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -7,6 +7,7 @@ import {
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
type AdminUsageResponse,
@ -27,9 +28,36 @@ type NewUserDraft = Required<Pick<AdminUserCreateRequest, "email" | "display_nam
Pick<UserDraft, "admin_access" | "account_status" | "affiliation" | "cohort_ids">;
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
const MAX_RENDERED_USERS = 40;
const MAX_RESOLVED_TICKET_HISTORY = 6;
const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [
{ value: "all", label: "전체 상태" },
{ value: "open", label: "미해결" },
{ value: "triaged", label: "분류됨" },
{ value: "in_progress", label: "처리 중" },
{ value: "resolved", label: "해결" },
{ value: "closed", label: "종결" },
];
const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [
{ value: "all", label: "전체 카테고리" },
{ value: "account_access", label: "계정/권한" },
{ value: "session_review", label: "세션/리뷰" },
{ value: "voice_browser", label: "음성/브라우저" },
{ value: "content_scenario", label: "콘텐츠/시나리오" },
{ value: "safety", label: "안전" },
{ value: "other", label: "기타" },
];
const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [
{ value: "all", label: "전체 우선순위" },
{ value: "urgent", label: "긴급" },
{ value: "high", label: "높음" },
{ value: "normal", label: "보통" },
{ value: "low", label: "낮음" },
];
const EMPTY_NEW_USER: NewUserDraft = {
email: "",
@ -310,6 +338,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState<string | null>(null);
const [updatingTicketId, setUpdatingTicketId] = useState<string | null>(null);
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>("all");
const [ticketCategoryFilter, setTicketCategoryFilter] = useState<TicketCategoryFilter>("all");
const [ticketPriorityFilter, setTicketPriorityFilter] = useState<TicketPriorityFilter>("all");
const [ticketSearch, setTicketSearch] = useState("");
const [ticketAssignedGroup, setTicketAssignedGroup] = useState("");
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const canGrantAdminAccess = currentUser?.superAdmin === true;
@ -384,13 +418,29 @@ export default function Admin({ section = "overview" }: AdminProps) {
setTicketsLoading(true);
setTicketsError(null);
try {
setTickets(await adminApi.tickets(undefined, 30));
const filters: AdminTicketFilters = {
status: ticketStatusFilter === "all" ? "" : ticketStatusFilter,
category: ticketCategoryFilter === "all" ? "" : ticketCategoryFilter,
priority: ticketPriorityFilter === "all" ? "" : ticketPriorityFilter,
assignedGroup: ticketAssignedGroup,
staleOnly: ticketStaleOnly,
search: ticketSearch,
windowDays: 30,
};
setTickets(await adminApi.tickets(filters));
} catch (err) {
setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
} finally {
setTicketsLoading(false);
}
}, []);
}, [
ticketAssignedGroup,
ticketCategoryFilter,
ticketPriorityFilter,
ticketSearch,
ticketStaleOnly,
ticketStatusFilter,
]);
useEffect(() => {
void loadHealth();
@ -636,6 +686,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
.slice(0, MAX_RESOLVED_TICKET_HISTORY),
[tickets],
);
const categoryQueues = useMemo(
() =>
Object.entries(tickets?.summary.by_category ?? {})
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]),
[tickets],
);
const hasTicketFilters =
ticketStatusFilter !== "all" ||
ticketCategoryFilter !== "all" ||
ticketPriorityFilter !== "all" ||
ticketStaleOnly ||
ticketAssignedGroup.trim().length > 0 ||
ticketSearch.trim().length > 0;
const clearTicketFilters = () => {
setTicketStatusFilter("all");
setTicketCategoryFilter("all");
setTicketPriorityFilter("all");
setTicketStaleOnly(false);
setTicketAssignedGroup("");
setTicketSearch("");
};
const filteredUsers = useMemo(() => {
const query = userSearch.trim().toLowerCase();
if (!query) return users;
@ -1473,6 +1545,79 @@ export default function Admin({ section = "overview" }: AdminProps) {
<div className="ad-users-note">
DB . .
</div>
<section className="ad-ticket-filter" aria-label="운영 티켓 필터">
<input
value={ticketSearch}
onChange={(event) => setTicketSearch(event.target.value)}
placeholder="제목, 본문, 신고자, 경로 검색"
aria-label="티켓 검색"
/>
<select
value={ticketStatusFilter}
onChange={(event) => setTicketStatusFilter(event.target.value as TicketStatusFilter)}
aria-label="상태 필터"
>
{TICKET_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketCategoryFilter}
onChange={(event) => setTicketCategoryFilter(event.target.value as TicketCategoryFilter)}
aria-label="카테고리 필터"
>
{TICKET_CATEGORY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={ticketPriorityFilter}
onChange={(event) => setTicketPriorityFilter(event.target.value as TicketPriorityFilter)}
aria-label="우선순위 필터"
>
{TICKET_PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<input
value={ticketAssignedGroup}
onChange={(event) => setTicketAssignedGroup(event.target.value)}
placeholder="담당 그룹"
aria-label="담당 그룹 필터"
/>
<label className="ad-ticket-filter__check">
<input
type="checkbox"
checked={ticketStaleOnly}
onChange={(event) => setTicketStaleOnly(event.target.checked)}
/>
<span></span>
</label>
<Button variant="secondary" onClick={clearTicketFilters} disabled={!hasTicketFilters}>
</Button>
</section>
{categoryQueues.length > 0 ? (
<div className="ad-ticket-queues" aria-label="카테고리 큐">
{categoryQueues.map(([category, count]) => (
<button
key={category}
type="button"
className={ticketCategoryFilter === category ? "is-active" : ""}
onClick={() => setTicketCategoryFilter(category as TicketCategoryFilter)}
>
<span>{ticketCategoryLabel(category as AdminSupportTicket["category"])}</span>
<b>{countLabel(count)}</b>
</button>
))}
</div>
) : null}
<section className="ad-section">
{ticketsError ? <InlineError message={ticketsError} /> : null}
@ -1525,6 +1670,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone={ticketTone(ticket)}>{ticketPriorityLabel(ticket.priority)}</Badge>
@ -1572,6 +1719,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
<p>{ticket.body}</p>
<small>
{ticket.source_path || "출처 없음"} · {dateTimeLabel(ticket.created_at)}
{ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
{ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}` : ""}
</small>
</div>
<Badge tone="neutral">{ticketPriorityLabel(ticket.priority)}</Badge>

View file

@ -43,7 +43,7 @@ export default function AvatarExpressionLab() {
const renderedAvatarCount = models.length * expressionCount;
return (
<AppShell contextLabel="Live2D QA">
<AppShell contextLabel="Live2D QA" navRole="learner">
<div
className="axl"
data-avatar-expression-lab="true"

View file

@ -16,7 +16,7 @@ const SEOYEON: AvatarPersona = {
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon-live2d-psb",
rasterArtSet: "seoyeon-live2d-psd-v2",
realism: 0.4,
expressionBias: "sad",
};
@ -46,14 +46,16 @@ export default function AvatarPreview() {
? "seoyeon"
: rig === "v3"
? "seoyeon-live2d-v3"
: SEOYEON.rasterArtSet,
: rig === "psb"
? "seoyeon-live2d-psb"
: SEOYEON.rasterArtSet,
};
return (
<div
className="ap"
data-avatar-preview="true"
data-avatar-rig={rig === "safe" ? "safe" : rig === "v3" ? "v3" : "psb"}
data-avatar-rig={rig === "safe" ? "safe" : rig === "v3" ? "v3" : rig === "psb" ? "psb" : "psd-v2"}
>
<header className="ap__head">
<h1> (raster / Live2D식)</h1>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,595 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
interface OnboardingForm {
legal_name: string;
affiliation: string;
department: string;
grade_level: string;
phone: string;
contact_address: string;
nickname: string;
self_introduction: string;
avatar_url: string;
terms_accepted: boolean;
privacy_accepted: boolean;
}
const EMPTY_FORM: OnboardingForm = {
legal_name: "",
affiliation: "한신대학교",
department: "",
grade_level: "",
phone: "",
contact_address: "",
nickname: "",
self_introduction: "",
avatar_url: "",
terms_accepted: false,
privacy_accepted: false,
};
function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
return {
...EMPTY_FORM,
legal_name: profile?.legal_name || profile?.display_name || "",
affiliation: profile?.affiliation || EMPTY_FORM.affiliation,
department: profile?.department || "",
grade_level: profile?.grade_level || "",
phone: profile?.phone || "",
contact_address: profile?.contact_address || "",
nickname: profile?.nickname || profile?.display_name || "",
self_introduction: profile?.self_introduction || "",
avatar_url: profile?.avatar_url || "",
};
}
export default function Onboarding() {
const navigate = useNavigate();
const { user, refresh } = useAuth();
const [docs, setDocs] = useState<LegalDocumentsResponse | null>(null);
const [form, setForm] = useState<OnboardingForm>(EMPTY_FORM);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [error, setError] = useState("");
const [avatarError, setAvatarError] = useState("");
useEffect(() => {
let alive = true;
(async () => {
setLoading(true);
setError("");
try {
const [profileResult, docsResult] = await Promise.all([userApi.me(), userApi.legalDocs()]);
if (!alive) return;
setDocs(docsResult);
setForm(profileToForm(profileResult));
} catch (err) {
console.warn("[onboarding] failed to load", err);
if (alive) setError("온보딩 정보를 불러오지 못했습니다.");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
const canSubmit = useMemo(
() =>
form.legal_name.trim() &&
form.affiliation.trim() &&
form.department.trim() &&
form.grade_level.trim() &&
form.phone.trim() &&
form.contact_address.trim() &&
form.nickname.trim() &&
form.self_introduction.trim() &&
form.terms_accepted &&
form.privacy_accepted,
[form],
);
const avatarSrc = form.avatar_url ? apiUrl(form.avatar_url) : "";
const update = <K extends keyof OnboardingForm>(key: K, value: OnboardingForm[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const uploadAvatar = async (file: File | null) => {
if (!file || uploadingAvatar) return;
setUploadingAvatar(true);
setAvatarError("");
try {
const uploaded = await userApi.uploadAvatar(file);
update("avatar_url", uploaded.avatar_url);
} catch (err) {
console.warn("[onboarding] avatar upload failed", err);
setAvatarError("아바타 업로드에 실패했습니다. PNG, JPG, WebP 파일을 3MB 이하로 올려 주세요.");
} finally {
setUploadingAvatar(false);
}
};
const submit = async () => {
if (!canSubmit || submitting) return;
setSubmitting(true);
setError("");
try {
await userApi.completeOnboarding({
legal_name: form.legal_name.trim(),
affiliation: form.affiliation.trim(),
department: form.department.trim(),
grade_level: form.grade_level.trim(),
phone: form.phone.trim(),
contact_address: form.contact_address.trim(),
nickname: form.nickname.trim(),
self_introduction: form.self_introduction.trim(),
avatar_url: form.avatar_url.trim(),
terms_accepted: form.terms_accepted,
privacy_accepted: form.privacy_accepted,
});
const nextUser = await refresh();
navigate(roleHomePath(nextUser?.role ?? user?.role ?? "learner"), { replace: true });
} catch (err) {
console.warn("[onboarding] failed to submit", err);
setError("온보딩 저장에 실패했습니다. 입력값과 서버 상태를 확인해 주세요.");
} finally {
setSubmitting(false);
}
};
return (
<>
<style>{ONBOARDING_CSS}</style>
<main className="ob-page">
<section className="ob-shell" aria-label="가입 정보 입력">
<header className="ob-head">
<p>Vignette </p>
<h1> .</h1>
</header>
<form
className="ob-form"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<section className="ob-section">
<div className="ob-section__head">
<h2></h2>
</div>
<div className="ob-avatar">
<div className="ob-avatar__preview" aria-hidden="true">
{avatarSrc ? <img src={avatarSrc} alt="" /> : <span>{form.nickname.trim().slice(0, 1) || "V"}</span>}
</div>
<div className="ob-avatar__body">
<span> </span>
<p> . .</p>
<label className="ob-avatar__button">
<input
type="file"
accept="image/png,image/jpeg,image/webp"
aria-label="아바타 이미지"
disabled={loading || uploadingAvatar}
onChange={(event) => {
void uploadAvatar(event.currentTarget.files?.[0] ?? null);
event.currentTarget.value = "";
}}
/>
{uploadingAvatar ? "업로드 중" : avatarSrc ? "다른 이미지 선택" : "이미지 선택"}
</label>
{avatarError ? <p className="ob-error" role="alert">{avatarError}</p> : null}
</div>
</div>
<div className="ob-fields">
<label>
<span></span>
<input
value={form.nickname}
onChange={(event) => update("nickname", event.target.value)}
autoComplete="nickname"
disabled={loading}
required
/>
</label>
<label className="ob-field--wide">
<span></span>
<textarea
value={form.self_introduction}
onChange={(event) => update("self_introduction", event.target.value)}
disabled={loading}
maxLength={600}
required
placeholder="상담 훈련에서 집중하고 싶은 점이나 본인을 소개할 문장을 적어 주세요."
/>
</label>
</div>
</section>
<section className="ob-section">
<div className="ob-section__head">
<h2> </h2>
</div>
<div className="ob-fields">
<label>
<span></span>
<input
value={form.legal_name}
onChange={(event) => update("legal_name", event.target.value)}
autoComplete="name"
disabled={loading}
required
/>
</label>
<label>
<span></span>
<input
value={form.affiliation}
onChange={(event) => update("affiliation", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.department}
onChange={(event) => update("department", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.grade_level}
onChange={(event) => update("grade_level", event.target.value)}
disabled={loading}
required
/>
</label>
<label>
<span></span>
<input
value={form.phone}
onChange={(event) => update("phone", event.target.value)}
autoComplete="tel"
disabled={loading}
required
/>
</label>
<label>
<span>/</span>
<input
value={form.contact_address}
onChange={(event) => update("contact_address", event.target.value)}
autoComplete="street-address"
disabled={loading}
required
/>
</label>
</div>
</section>
<section className="ob-section">
<div className="ob-section__head">
<h2></h2>
</div>
<div className="ob-checks">
<label>
<input
type="checkbox"
checked={form.terms_accepted}
onChange={(event) => update("terms_accepted", event.target.checked)}
disabled={loading}
/>
<span> .</span>
</label>
<label>
<input
type="checkbox"
checked={form.privacy_accepted}
onChange={(event) => update("privacy_accepted", event.target.checked)}
disabled={loading}
/>
<span> .</span>
</label>
</div>
<div className="ob-legal">
<details>
<summary>
<span>{docs?.terms.title ?? "서비스 이용약관"}</span>
<b>{docs?.terms.version ?? "확인 중"}</b>
</summary>
<div className="ob-doc-text">{docs?.terms.body ?? "문서를 불러오는 중입니다."}</div>
</details>
<details>
<summary>
<span>{docs?.privacy.title ?? "개인정보 처리방침"}</span>
<b>{docs?.privacy.version ?? "확인 중"}</b>
</summary>
<div className="ob-doc-text">{docs?.privacy.body ?? "문서를 불러오는 중입니다."}</div>
</details>
<p className="ob-note">{docs?.source_note ?? "운영 전 초안 문서입니다."}</p>
</div>
</section>
{error ? <p className="ob-error" role="alert">{error}</p> : null}
{loading ? <p className="ob-note"> .</p> : null}
<div className="ob-actions">
<Button type="submit" size="lg" disabled={!canSubmit || submitting || loading}>
{submitting ? "저장 중" : "가입 설정 완료"}
</Button>
</div>
</form>
</section>
</main>
</>
);
}
const ONBOARDING_CSS = `
.ob-page{
min-height:100dvh;
width:100%;
background:var(--bg-app);
color:var(--text-body);
padding:clamp(20px,5vw,56px);
}
.ob-shell{
width:100%;
max-width:900px;
margin:0 auto;
display:grid;
gap:var(--sp-6);
}
.ob-head{
display:grid;
gap:8px;
}
.ob-head p{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:800;
}
.ob-head h1{
margin:0;
color:var(--text-strong);
font-size:clamp(28px,4vw,44px);
line-height:1.18;
letter-spacing:0;
}
.ob-form{
min-width:0;
display:grid;
gap:var(--sp-6);
}
.ob-section{
min-width:0;
display:grid;
gap:var(--sp-4);
padding-bottom:var(--sp-5);
border-bottom:1px solid var(--border-subtle);
}
.ob-section__head h2{
margin:0;
color:var(--text-strong);
font-size:18px;
line-height:1.35;
letter-spacing:0;
}
.ob-avatar{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:var(--sp-3);
align-items:center;
}
.ob-avatar__preview{
width:72px;
height:72px;
border-radius:50%;
display:grid;
place-items:center;
overflow:hidden;
background:var(--accent-tint);
color:var(--accent-deep);
font-size:28px;
font-weight:800;
border:1px solid var(--border-subtle);
}
.ob-avatar__preview img{
width:100%;
height:100%;
display:block;
object-fit:cover;
}
.ob-avatar__body{
min-width:0;
display:grid;
gap:7px;
}
.ob-avatar__body span{
color:var(--text-strong);
font-size:13px;
font-weight:780;
}
.ob-avatar__body p{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.ob-avatar__button{
position:relative;
width:max-content;
min-height:34px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-strong);
font-size:12px;
font-weight:760;
cursor:pointer;
}
.ob-avatar__button input{
position:absolute;
inline-size:1px;
block-size:1px;
opacity:0;
pointer-events:none;
}
.ob-field--wide{
grid-column:1 / -1;
}
.ob-fields{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:var(--sp-3);
}
.ob-form label{
min-width:0;
display:grid;
gap:7px;
}
.ob-form label span{
color:var(--text-muted);
font-size:12px;
font-weight:730;
}
.ob-form input[type="text"],
.ob-form input:not([type]){
min-width:0;
}
.ob-form input,
.ob-form textarea{
width:100%;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-strong);
padding:0 12px;
font:500 var(--fs-sm)/1.2 var(--font-sans);
}
.ob-form input{
min-height:42px;
}
.ob-form textarea{
min-height:92px;
padding:11px 12px;
resize:vertical;
line-height:1.45;
}
.ob-form input:focus,
.ob-form textarea:focus{
outline:2px solid color-mix(in srgb,var(--accent) 24%,transparent);
border-color:var(--accent);
}
.ob-checks{
display:grid;
gap:10px;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.ob-checks label{
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
}
.ob-checks input{
width:18px;
height:18px;
min-height:18px;
padding:0;
accent-color:var(--accent);
}
.ob-legal{
min-width:0;
display:grid;
gap:8px;
}
.ob-legal details{
min-width:0;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ob-legal summary{
min-width:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-height:42px;
padding:0 12px;
cursor:pointer;
}
.ob-legal summary span{
min-width:0;
color:var(--text-strong);
font-size:13px;
font-weight:760;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ob-legal summary b{
flex:none;
color:var(--text-muted);
font-size:11px;
font-weight:700;
}
.ob-note,
.ob-error{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.5;
}
.ob-doc-text{
max-height:260px;
overflow:auto;
white-space:pre-wrap;
color:var(--text-body);
font-size:12.5px;
line-height:1.55;
padding:0 12px 12px;
}
.ob-error{
color:var(--crit-text);
}
.ob-actions{
display:flex;
justify-content:flex-start;
}
@media (max-width:620px){
.ob-page{
padding:14px;
}
.ob-fields{
grid-template-columns:1fr;
}
.ob-avatar{
grid-template-columns:1fr;
}
.ob-actions .vg-btn{
width:100%;
}
}
`;

View file

@ -0,0 +1,176 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Icon } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
export default function PendingApproval() {
const navigate = useNavigate();
const { user, logout, refresh } = useAuth();
const [checking, setChecking] = useState(false);
const checkAgain = async () => {
if (checking) return;
setChecking(true);
try {
const nextUser = await refresh();
if (nextUser?.accountStatus === "approved") {
navigate(
nextUser.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(nextUser.role),
{ replace: true },
);
}
} finally {
setChecking(false);
}
};
const signOut = async () => {
await logout();
navigate("/login", { replace: true });
};
const statusText = user?.accountStatus === "suspended" ? "접속 보류" : "승인 대기";
return (
<>
<style>{PENDING_APPROVAL_CSS}</style>
<main className="pa-page" aria-label="계정 승인 대기">
<section className="pa-panel">
<div className="pa-mark" aria-hidden="true">
<Icon name="shield" size={30} strokeWidth={1.9} />
</div>
<p className="pa-kicker">{statusText}</p>
<h1> .</h1>
<p className="pa-copy">
{user?.email ? <b>{user.email}</b> : "현재 계정"} .
Vignette .
</p>
<div className="pa-status" role="status">
<span aria-hidden="true" />
<div>
<b> </b>
<small> .</small>
</div>
</div>
<div className="pa-actions">
<Button
type="button"
size="lg"
leading={<Icon name="settings" size={16} />}
onClick={() => void checkAgain()}
disabled={checking}
>
{checking ? "확인 중" : "승인 상태 새로고침"}
</Button>
<Button
type="button"
size="lg"
variant="secondary"
leading={<Icon name="logout" size={16} />}
onClick={() => void signOut()}
>
</Button>
</div>
</section>
</main>
</>
);
}
const PENDING_APPROVAL_CSS = `
.pa-page{
min-height:100dvh;
display:grid;
place-items:center;
padding:clamp(18px,5vw,56px);
background:var(--bg-app);
color:var(--text-body);
}
.pa-panel{
width:min(100%,620px);
display:grid;
gap:var(--sp-4);
padding:clamp(24px,5vw,44px);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
box-shadow:0 18px 50px rgba(28,43,40,.10);
}
.pa-mark{
width:62px;
height:62px;
display:grid;
place-items:center;
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
border:1px solid color-mix(in srgb,var(--accent) 20%,transparent);
}
.pa-kicker{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:820;
}
.pa-panel h1{
margin:0;
color:var(--text-strong);
font-size:clamp(30px,5vw,46px);
line-height:1.17;
letter-spacing:0;
}
.pa-copy{
margin:0;
color:var(--text-muted);
font-size:15px;
line-height:1.7;
}
.pa-copy b{
color:var(--text-strong);
font-weight:760;
}
.pa-status{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:12px;
align-items:center;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.pa-status > span{
width:10px;
height:10px;
border-radius:50%;
background:#d89a2b;
box-shadow:0 0 0 5px rgba(216,154,43,.14);
}
.pa-status b{
display:block;
color:var(--text-strong);
font-size:14px;
}
.pa-status small{
display:block;
margin-top:3px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.pa-actions{
display:flex;
flex-wrap:wrap;
gap:10px;
}
@media (max-width:560px){
.pa-panel{
border-radius:var(--radius);
}
.pa-actions .vg-btn{
width:100%;
}
}
`;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
@ -12,6 +12,7 @@ import {
} from "../components/ui";
import {
sessionApi,
teacherApi,
type ReviewCaseWorksheet,
type ReviewNonverbalEvent,
type ReviewNote,
@ -41,6 +42,120 @@ function renderSummary(summary: string) {
});
}
function renderInlineMarkdown(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let cursor = 0;
let key = 0;
const pushText = (value: string) => {
if (value) nodes.push(value);
};
while (cursor < text.length) {
const codeAt = text.indexOf("`", cursor);
const strongAt = text.indexOf("**", cursor);
const hasCode = codeAt >= 0;
const hasStrong = strongAt >= 0;
if (!hasCode && !hasStrong) {
pushText(text.slice(cursor));
break;
}
const useCode = hasCode && (!hasStrong || codeAt < strongAt);
const markerAt = useCode ? codeAt : strongAt;
if (markerAt > cursor) pushText(text.slice(cursor, markerAt));
if (useCode) {
const end = text.indexOf("`", markerAt + 1);
if (end < 0) {
pushText(text.slice(markerAt));
break;
}
const value = text.slice(markerAt + 1, end);
nodes.push(<code key={`code-${key++}`}>{value}</code>);
cursor = end + 1;
} else {
const end = text.indexOf("**", markerAt + 2);
if (end < 0) {
pushText(text.slice(markerAt));
break;
}
const value = text.slice(markerAt + 2, end);
nodes.push(<strong key={`strong-${key++}`}>{value}</strong>);
cursor = end + 2;
}
}
return nodes;
}
function ReviewMarkdown({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
if (trimmed.startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
i += 1;
}
blocks.push(
<blockquote key={`quote-${blocks.length}`} className="sr-md__quote">
{quoteLines.map((quoteLine, index) => (
<span key={`${quoteLine}-${index}`}>{renderInlineMarkdown(quoteLine)}</span>
))}
</blockquote>,
);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
i += 1;
}
blocks.push(
<ul key={`list-${blocks.length}`} className="sr-md__list">
{items.map((item, index) => (
<li key={`${item}-${index}`}>{renderInlineMarkdown(item)}</li>
))}
</ul>,
);
continue;
}
const paragraph: string[] = [];
while (
i < lines.length &&
lines[i].trim() &&
!lines[i].trim().startsWith(">") &&
!/^[-*]\s+/.test(lines[i].trim())
) {
paragraph.push(lines[i].trim());
i += 1;
}
blocks.push(
<p key={`p-${blocks.length}`} className="sr-md__p">
{renderInlineMarkdown(paragraph.join(" "))}
</p>,
);
}
return <div className="sr-md">{blocks}</div>;
}
function EmptyBlock({ title, desc }: { title: string; desc: string }) {
return (
<div className="vg-empty sr-empty">
@ -69,10 +184,18 @@ function NonverbalChip({ event }: { event: ReviewNonverbalEvent }) {
);
}
function noteAuthorLabel(author: string) {
const normalized = author.trim();
if (normalized === "ai") return "AI";
if (normalized === "transcript") return "기록";
return normalized || "교수자";
}
function SupervisorCallout({ note }: { note: ReviewNote }) {
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
const iconName = note.tone === "good" ? "check" : "info";
const tag = note.author === "ai" ? "AI" : note.author === "transcript" ? "기록" : "교수자";
const positiveTone = note.tone === "good" || note.tone === "ai";
const toneCls = positiveTone ? "sr-note--ai" : "sr-note--warn";
const iconName = positiveTone ? "check" : "info";
const tag = noteAuthorLabel(note.author);
return (
<div className={`sr-note ${toneCls}`}>
<div className="sr-note__head">
@ -80,15 +203,15 @@ function SupervisorCallout({ note }: { note: ReviewNote }) {
{note.title}
<span className="sr-note__tag">{tag}</span>
</div>
<p className="sr-note__txt">
{note.body}
<div className="sr-note__body">
<ReviewMarkdown text={note.body} />
{note.quote ? (
<>
{" "}
<span className="sr-quote">{note.quote}</span>
</>
<blockquote className="sr-note__quote">
<span> </span>
<p>{note.quote}</p>
</blockquote>
) : null}
</p>
</div>
</div>
);
}
@ -146,11 +269,13 @@ function CaseWorksheetCard({
worksheet,
onJump,
onSaved,
readOnly,
}: {
sessionId: string;
worksheet: ReviewCaseWorksheet | null | undefined;
onJump: (id: string) => void;
onSaved: (worksheet: ReviewCaseWorksheet) => void;
readOnly: boolean;
}) {
const sections = worksheet?.sections ?? [];
const limitations = worksheet?.limitations ?? [];
@ -161,6 +286,13 @@ function CaseWorksheetCard({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const isSaved = worksheet?.status === "saved_by_learner";
const stateLabel = readOnly
? isSaved
? "학습자 저장본 읽기 전용"
: "축어록 자동 초안 읽기 전용"
: isSaved
? "저장된 학습자 제출본"
: "축어록 기반 자동 초안";
useEffect(() => {
setDraftSections(cloneWorksheetSections(sections));
@ -213,16 +345,20 @@ function CaseWorksheetCard({
<div className="sr-ws-toolbar">
<div>
<Kicker> </Kicker>
<span className="sr-ws-state">{isSaved ? "저장된 학습자 제출본" : "축어록 기반 자동 초안"}</span>
<span className="sr-ws-state">{stateLabel}</span>
</div>
<Button
variant="secondary"
size="sm"
disabled={!sections.length || !dirty || saving}
onClick={saveWorksheet}
>
{saving ? "저장 중" : dirty ? "저장" : "저장됨"}
</Button>
{readOnly ? (
<span className="sr-ws-readonly"> </span>
) : (
<Button
variant="secondary"
size="sm"
disabled={!sections.length || !dirty || saving}
onClick={saveWorksheet}
>
{saving ? "저장 중" : dirty ? "저장" : "저장됨"}
</Button>
)}
</div>
<div className="sr-worksheet">
{draftSections.length > 0 ? (
@ -241,10 +377,11 @@ function CaseWorksheetCard({
</span>
</div>
<textarea
className="sr-ws-input"
className={`sr-ws-input ${readOnly ? "is-readonly" : ""}`}
value={item.value ?? ""}
placeholder={item.emptyReason || "근거 대기"}
rows={3}
readOnly={readOnly}
onChange={(event) =>
updateValue(section.key, item.key, event.currentTarget.value)
}
@ -285,16 +422,27 @@ function CaseWorksheetCard({
export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const location = useLocation();
const [data, setData] = useState<SessionReviewResponse | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [learnerOnly, setLearnerOnly] = useState(false);
const [activeTurn, setActiveTurn] = useState<string | null>(null);
const [shareState, setShareState] = useState<"idle" | "creating" | "copied" | "error">("idle");
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [shareError, setShareError] = useState<string | null>(null);
const [teacherNote, setTeacherNote] = useState("");
const [teacherReviewSaving, setTeacherReviewSaving] = useState<"viewed" | "closed" | null>(null);
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
setData((current) => (current ? { ...current, caseWorksheet: worksheet } : current));
};
const isSupervisorView = location.pathname.startsWith("/teach/");
const reviewNavRole = isSupervisorView ? "teacher" : "learner";
const contextLabel = isSupervisorView ? "교수자 회기 리뷰" : "회기 리뷰";
useEffect(() => {
let alive = true;
@ -313,6 +461,8 @@ export default function SessionReview() {
const next = await sessionApi.review(sessionId);
if (!alive) return;
setData(next);
setTeacherNote(next.teacherReview?.note ?? "");
setTeacherReviewError(null);
setLoadState("ready");
} catch (err) {
if (!alive) return;
@ -351,9 +501,61 @@ export default function SessionReview() {
});
}
async function handleCreateShare() {
if (!sessionId) return;
setShareState("creating");
setShareError(null);
try {
const share = await sessionApi.createShare(sessionId);
setShareUrl(share.shareUrl);
try {
await navigator.clipboard?.writeText(share.shareUrl);
setShareState("copied");
} catch {
setShareState("copied");
}
} catch (err) {
setShareState("error");
setShareError(err instanceof Error ? err.message : "공유 URL을 만들지 못했습니다.");
}
}
async function saveTeacherReviewStatus(status: "viewed" | "closed") {
if (!sessionId || !data) return;
setTeacherReviewSaving(status);
setTeacherReviewError(null);
try {
const saved = await teacherApi.updateSessionReviewStatus(sessionId, {
status,
note: teacherNote,
});
setData((current) =>
current
? {
...current,
teacherReview: {
status: saved.status,
note: saved.note,
reviewerId: saved.reviewer_id ?? null,
reviewedAt: saved.reviewed_at ?? null,
updatedAt: saved.updated_at ?? null,
},
}
: current,
);
setTeacherNote(saved.note);
} catch (err) {
setTeacherReviewError(
err instanceof Error ? err.message : "교수자 검토 상태를 저장하지 못했습니다.",
);
} finally {
setTeacherReviewSaving(null);
}
}
if (loadState === "loading") {
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
<Card>
<Kicker> </Kicker>
@ -366,7 +568,7 @@ export default function SessionReview() {
if (loadState === "error" || !data) {
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
<Card>
<Kicker> </Kicker>
@ -393,12 +595,22 @@ export default function SessionReview() {
const canOpenAudio = Boolean(data.audioUrl);
const canExportPdf = Boolean(data.pdfExportUrl);
const hasTranscript = turns.length > 0;
const canCreateShare = !isSupervisorView && data.sessionSignal === "종료됨";
const teacherReview = data.teacherReview;
const teacherReviewStatus = teacherReview?.status ?? "pending";
const teacherReviewStatusLabel =
teacherReviewStatus === "closed"
? "검토 완료"
: teacherReviewStatus === "viewed"
? "검토 중"
: "검토 대기";
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
const reviewReadiness = hasTranscript
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
return (
<AppShell contextLabel="회기 리뷰">
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className={`sr-root ${hasTranscript ? "" : "sr-root--empty"}`}>
<Card className="sr-head">
<div className="sr-head__id">
@ -413,6 +625,9 @@ export default function SessionReview() {
<span>{data.durationLabel}</span>
<span className="sr-meta-sep" aria-hidden="true" />
<span>{data.client.persona}</span>
<Badge tone={isSupervisorView ? "neutral" : "accent"}>
{isSupervisorView ? "교수자 검토 화면" : "학습자 리뷰"}
</Badge>
<Badge tone="accent">{data.reachedPhase}</Badge>
</div>
</div>
@ -438,10 +653,10 @@ export default function SessionReview() {
</div>
</Card>
<div className="sr-cols">
<div className={`sr-cols ${isSupervisorView ? "sr-cols--supervisor" : ""}`}>
<div className="sr-left">
<Card className="sr-overview">
<Kicker> </Kicker>
<Kicker> </Kicker>
<p className="sr-summary">
{renderSummary(data.summary)}
</p>
@ -460,6 +675,16 @@ export default function SessionReview() {
</span>
</div>
<div className="sr-actions">
{isSupervisorView ? (
<Button
variant="secondary"
size="sm"
leading={<Icon name="users" size={14} />}
onClick={() => navigate("/teach")}
>
</Button>
) : null}
<Button
variant="primary"
size="sm"
@ -482,6 +707,28 @@ export default function SessionReview() {
>
PDF
</Button>
{!isSupervisorView ? (
<>
<Button
variant="secondary"
size="sm"
leading={<Icon name="share" size={14} />}
disabled={!canCreateShare || shareState === "creating"}
onClick={handleCreateShare}
>
{shareState === "creating"
? "공유 링크 생성 중"
: shareState === "copied"
? "공유 URL 복사됨"
: "공유 URL 복사"}
</Button>
{shareUrl || shareError ? (
<p className={`sr-share-note ${shareError ? "sr-share-note--error" : ""}`}>
{shareError ? shareError : shareUrl}
</p>
) : null}
</>
) : null}
</div>
</Card>
@ -623,6 +870,69 @@ export default function SessionReview() {
</div>
<div className="sr-right">
{isSupervisorView ? (
<Card className="sr-card sr-card--side sr-card--teacher-review">
<div className="sr-teacher-review__head">
<div>
<Kicker> </Kicker>
<b>{teacherReviewStatusLabel}</b>
</div>
<Badge tone={teacherReviewStatus === "closed" ? "accent" : "neutral"}>
{teacherReviewStatusLabel}
</Badge>
</div>
<label className="sr-teacher-review__note">
<span> </span>
<textarea
value={teacherNote}
rows={4}
placeholder="다음 지도에서 확인할 점을 남깁니다."
onChange={(event) => setTeacherNote(event.target.value)}
maxLength={2000}
/>
</label>
{teacherReview?.reviewedAt ? (
<p className="sr-teacher-review__meta">
{teacherReview.reviewedAt}
</p>
) : canCloseTeacherReview ? (
<p className="sr-teacher-review__meta">
.
</p>
) : (
<p className="sr-teacher-review__meta">
.
</p>
)}
{teacherReviewError ? (
<p className="sr-teacher-review__error" role="alert">
{teacherReviewError}
</p>
) : null}
<div className="sr-teacher-review__actions">
<Button
variant="secondary"
size="sm"
disabled={teacherReviewSaving !== null}
onClick={() => void saveTeacherReviewStatus("viewed")}
>
{teacherReviewSaving === "viewed" ? "저장 중" : "메모 저장"}
</Button>
<Button
size="sm"
disabled={
!canCloseTeacherReview ||
teacherReviewSaving !== null ||
teacherReviewStatus === "closed"
}
onClick={() => void saveTeacherReviewStatus("closed")}
>
{teacherReviewSaving === "closed" ? "완료 중" : "검토 완료"}
</Button>
</div>
</Card>
) : null}
<Card className="sr-card sr-card--side sr-card--rubric">
<Kicker> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
@ -713,6 +1023,7 @@ export default function SessionReview() {
worksheet={data.caseWorksheet}
onJump={jumpToTurn}
onSaved={handleWorksheetSaved}
readOnly={isSupervisorView}
/>
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>

View file

@ -388,7 +388,7 @@ export default function Settings() {
const notificationPreferences = completeNotificationPreferences(preferences?.notifications);
return (
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
<AppShell contextLabel="설정">
{error ? (
<div className="vg-set__callout vg-set__callout--warn" role="alert">
<span className="vg-set__callout-ico">

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
.ob-root {
width: min(100%, 1040px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.ob-head h1 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-h2);
line-height: 1.28;
letter-spacing: 0;
}
.ob-head p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.55;
}
.ob-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
background: var(--crit-tint);
color: var(--crit-text);
font-size: var(--fs-sm);
}
.ob-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
align-items: start;
}
.ob-panel {
min-width: 0;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
.ob-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
}
.ob-form .vg-btn,
.ob-check {
grid-column: 1 / -1;
}
.ob-check {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.4;
}
.ob-check input {
width: 17px;
height: 17px;
accent-color: var(--accent);
}
.ob-docs {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.ob-docs h2 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-body);
}
.ob-docs p {
margin: 6px 0 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
}
.ob-docs article {
min-width: 0;
max-height: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.ob-docs b,
.ob-docs small {
display: block;
}
.ob-docs b {
color: var(--text-strong);
font-size: var(--fs-sm);
}
.ob-docs small {
margin-top: 2px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
}
@media (max-width: 860px) {
.ob-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.ob-form {
grid-template-columns: 1fr;
}
}

View file

@ -3,23 +3,9 @@
.sr-root {
width: min(100%, 1360px);
margin: 0 auto;
position: relative;
isolation: isolate;
display: grid;
gap: var(--sp-5);
}
.sr-root::before {
content: "";
position: absolute;
z-index: -1;
inset: -90px 0 auto auto;
width: min(620px, 56vw);
height: 440px;
background: var(--asset-warm-elements) center / cover no-repeat;
opacity: .05;
filter: saturate(.8);
pointer-events: none;
}
.sr-root--empty {
--sr-empty-tone: var(--neutral-sig);
}
@ -128,18 +114,29 @@
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(230px, 0.62fr) minmax(0, 1.22fr) minmax(280px, 0.68fr);
grid-template-columns: minmax(236px, 280px) minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas:
"overview chart rubric"
"overview flow good"
"transcript transcript growth"
"transcript transcript feedback"
"worksheet worksheet worksheet"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
gap: var(--sp-4);
align-items: start;
margin-top: 0;
}
.sr-cols--supervisor {
grid-template-areas:
"overview chart teacher"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
}
.sr-left,
.sr-right {
display: contents;
@ -148,10 +145,14 @@
grid-area: overview;
min-width: 0;
display: grid;
gap: var(--sp-4);
gap: var(--sp-3);
align-self: start;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
z-index: 1;
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
}
.sr-feedback {
grid-area: feedback;
@ -165,6 +166,9 @@
.sr-card--rubric {
grid-area: rubric;
}
.sr-card--teacher-review {
grid-area: teacher;
}
.sr-card--good {
grid-area: good;
}
@ -192,6 +196,68 @@
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
}
.sr-teacher-review__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.sr-teacher-review__head b {
display: block;
margin-top: 6px;
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.35;
}
.sr-teacher-review__note {
display: grid;
gap: 7px;
}
.sr-teacher-review__note span {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
}
.sr-teacher-review__note textarea {
width: 100%;
min-height: 104px;
resize: vertical;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
color: var(--text-body);
padding: 10px 12px;
font: inherit;
font-size: var(--fs-sm);
line-height: 1.5;
}
.sr-teacher-review__note textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
.sr-teacher-review__meta,
.sr-teacher-review__error {
margin: var(--sp-3) 0 0;
font-size: var(--fs-xs);
line-height: 1.45;
}
.sr-teacher-review__meta {
color: var(--text-muted);
}
.sr-teacher-review__error {
color: var(--crit-text);
}
.sr-teacher-review__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.sr-teacher-review__actions .vg-btn {
width: 100%;
}
.sr-summary {
max-width: 68ch;
@ -203,27 +269,32 @@
line-height: 1.45;
}
.sr-overview .sr-summary {
font-size: clamp(18px, 1.26vw, 21px);
font-size: var(--fs-sm);
font-weight: 520;
line-height: 1.58;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 12;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
.sr-summary .sr-hl {
color: var(--accent-deep);
}
.sr-overview .sr-summary .sr-hl {
font-weight: 650;
}
.sr-readiness {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
padding-top: var(--sp-4);
gap: 8px;
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-readiness span {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-surface-2);
}
@ -246,6 +317,24 @@
.sr-actions .vg-btn {
width: 100%;
}
.sr-share-note {
min-width: 0;
margin: calc(var(--sp-2) * -1) 0 0;
padding: 8px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.sr-share-note--error {
color: var(--crit-text);
background: var(--crit-tint);
border-color: color-mix(in srgb, var(--crit-text) 28%, var(--border-subtle));
}
.sr-empty {
max-width: none;
@ -646,13 +735,66 @@
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sr-note__txt {
margin: 0;
.sr-note__body {
display: grid;
gap: 8px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.58;
}
.sr-note__txt .sr-quote {
.sr-md {
display: grid;
gap: 7px;
}
.sr-md__p {
margin: 0;
}
.sr-md code {
display: inline;
padding: 1px 5px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 5px;
font-family: var(--font-num);
font-size: .92em;
color: var(--text-strong);
background: color-mix(in srgb, var(--bg-surface) 74%, transparent);
white-space: normal;
word-break: break-word;
}
.sr-md strong {
color: var(--text-strong);
font-weight: 720;
}
.sr-md__list {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
.sr-md__quote,
.sr-note__quote {
margin: 0;
padding: 8px 10px;
border-left: 3px solid color-mix(in srgb, var(--accent) 70%, var(--border-strong));
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-strong);
background: color-mix(in srgb, var(--bg-surface) 72%, var(--accent-tint));
}
.sr-md__quote {
display: grid;
gap: 3px;
}
.sr-note__quote span {
display: block;
margin-bottom: 3px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
}
.sr-note__quote p {
margin: 0;
color: var(--text-strong);
font-style: italic;
}
@ -737,6 +879,20 @@
font-size: 11px;
line-height: 1.35;
}
.sr-ws-readonly {
flex: none;
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
color: var(--text-muted);
background: var(--bg-surface-2);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.sr-ws-section {
min-width: 0;
display: grid;
@ -815,6 +971,16 @@
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
}
.sr-ws-input.is-readonly {
resize: none;
cursor: default;
color: var(--text-body);
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
}
.sr-ws-input.is-readonly:focus {
outline: none;
border-color: var(--border-subtle);
}
.sr-ws-input::placeholder {
color: var(--text-muted);
}
@ -867,9 +1033,7 @@
/* Filled state: only a real client quote earns the high-contrast stage card. */
.sr-feedback--filled {
border-color: transparent;
background:
linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82));
box-shadow: var(--shadow-sm);
}
.sr-feedback__kicker {
@ -1006,25 +1170,17 @@
overflow-wrap: anywhere;
}
[data-theme="dark"] .sr-root::before {
opacity: 0.1;
filter: saturate(0.9) contrast(1.06);
}
[data-theme="dark"] .sr-head,
[data-theme="dark"] .sr-card,
[data-theme="dark"] .sr-overview {
background:
linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96));
border-color: rgba(203, 227, 220, 0.13);
box-shadow:
0 16px 42px rgba(3, 9, 8, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
[data-theme="dark"] .sr-card--transcript {
background:
linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
}
[data-theme="dark"] .sr-stat,
[data-theme="dark"] .sr-readiness span,
@ -1057,9 +1213,7 @@
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
background:
linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92)),
var(--asset-warm-elements) center / cover no-repeat;
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
}
@media (max-width: 1180px) {
@ -1075,8 +1229,23 @@
"worksheet worksheet"
"session session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"rubric rubric"
"good growth"
"transcript transcript"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -1097,9 +1266,6 @@
.sr-root {
gap: var(--sp-4);
}
.sr-root::before {
display: none;
}
.sr-head {
grid-template-columns: 1fr;
gap: var(--sp-4);
@ -1123,12 +1289,28 @@
"session";
gap: var(--sp-4);
}
.sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"rubric"
"good"
"growth"
"transcript"
"feedback"
"worksheet"
"session";
}
.sr-summary {
font-size: 20px;
line-height: 1.45;
}
.sr-overview .sr-summary {
-webkit-line-clamp: 8;
font-size: clamp(18px, 1.26vw, 21px);
font-weight: 650;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -1200,6 +1382,9 @@
flex-basis: 100%;
min-height: 38px;
}
.sr-teacher-review__actions {
grid-template-columns: 1fr;
}
.sr-feedback,
.sr-card,
.sr-overview {

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,10 @@
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
body[data-page="settings"] .vg-shell {
background:
linear-gradient(135deg, rgba(251, 250, 248, 0.96), rgba(244, 242, 238, 0.92)),
var(--asset-warm-elements) center / cover no-repeat;
background: var(--bg-app);
}
body[data-page="settings"] .vg-topbar {
background: rgba(251, 250, 248, 0.94);
background: var(--bg-surface);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
@ -1539,22 +1537,14 @@ body[data-page="settings"] .vg-set__pill {
}
html[data-theme="dark"] body[data-page="settings"] .vg-shell {
background:
radial-gradient(circle at 12% 0%, rgba(111, 179, 164, 0.18), transparent 34%),
radial-gradient(circle at 92% 12%, rgba(205, 154, 116, 0.12), transparent 32%),
linear-gradient(135deg, rgba(11, 25, 22, 0.98), rgba(18, 30, 27, 0.96)),
var(--asset-warm-elements) center / cover no-repeat;
background: var(--bg-app);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail,
html[data-theme="dark"] body[data-page="settings"] .vg-set__group {
border-color: rgba(203, 227, 220, 0.13);
background:
linear-gradient(180deg, rgba(28, 44, 39, 0.93), rgba(16, 29, 26, 0.95)),
rgba(14, 27, 24, 0.88);
box-shadow:
0 24px 58px rgba(0, 0, 0, 0.26),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
border-color: var(--border-subtle);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
html[data-theme="dark"] body[data-page="settings"] .vg-set__rail-title h1,