음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
595
apps/web/src/pages/Onboarding.tsx
Normal file
595
apps/web/src/pages/Onboarding.tsx
Normal 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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
176
apps/web/src/pages/PendingApproval.tsx
Normal file
176
apps/web/src/pages/PendingApproval.tsx
Normal 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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
2199
apps/web/src/pages/PersonaStudio.tsx
Normal file
2199
apps/web/src/pages/PersonaStudio.tsx
Normal file
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
|
|
@ -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" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
1411
apps/web/src/pages/admin/admin-console.css
Normal file
1411
apps/web/src/pages/admin/admin-console.css
Normal file
File diff suppressed because it is too large
Load diff
134
apps/web/src/pages/onboarding.css
Normal file
134
apps/web/src/pages/onboarding.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue