1220 lines
35 KiB
TypeScript
1220 lines
35 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { AppShell } from "../components/shell/AppShell";
|
||
import { Button, Icon, Kicker } from "../components/ui";
|
||
import {
|
||
personaApi,
|
||
sessionApi,
|
||
type LearnerSessionSummary,
|
||
type PersonaSummary,
|
||
} from "../lib/api";
|
||
|
||
type LoadState = "loading" | "ready" | "error";
|
||
|
||
const DIFFICULTY_LABEL: Record<string, string> = {
|
||
easy: "기초",
|
||
moderate: "중간",
|
||
hard: "고난도",
|
||
};
|
||
|
||
function firstText(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const text = value.trim();
|
||
return text || null;
|
||
}
|
||
|
||
function shortName(displayName: string): string {
|
||
return displayName.split("·")[0]?.replace(/\(.*?\)/g, "").trim() || displayName.trim();
|
||
}
|
||
|
||
function demographicsLine(persona: PersonaSummary | null): string {
|
||
if (!persona) return "내담자 정보를 불러오는 중";
|
||
const parts = [
|
||
firstText(persona.demographics.age_band),
|
||
firstText(persona.demographics.grade),
|
||
firstText(persona.demographics.job),
|
||
firstText(persona.demographics.status),
|
||
].filter(Boolean);
|
||
return parts.length ? parts.join(" · ") : "인적 정보 없음";
|
||
}
|
||
|
||
function theoryLine(persona: PersonaSummary | null): string {
|
||
if (!persona) return "-";
|
||
return persona.theory_target.length ? persona.theory_target.join(" · ") : "공통";
|
||
}
|
||
|
||
function difficultyLine(persona: PersonaSummary | null): string {
|
||
if (!persona) return "-";
|
||
return DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty;
|
||
}
|
||
|
||
function personaInitial(persona: PersonaSummary | null): string {
|
||
const name = persona ? shortName(persona.display_name) : "";
|
||
return (name || persona?.code || "V").slice(0, 1);
|
||
}
|
||
|
||
function isUsablePersona(persona: PersonaSummary): boolean {
|
||
return !persona.degraded && persona.source === "database";
|
||
}
|
||
|
||
function unavailablePersonaMessage(persona: PersonaSummary): string {
|
||
if (persona.degraded) {
|
||
return "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.";
|
||
}
|
||
return "데이터베이스에서 확인된 내담자가 아니어서 현재 연습에 사용할 수 없습니다.";
|
||
}
|
||
|
||
function sessionStatusLabel(session: LearnerSessionSummary): string {
|
||
if (session.status === "active") return "진행 중";
|
||
if (session.review_ready) return "리뷰 가능";
|
||
return "종료";
|
||
}
|
||
|
||
function sessionDateLabel(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return new Intl.DateTimeFormat("ko-KR", {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(date);
|
||
}
|
||
|
||
export default function LearnerHome() {
|
||
const navigate = useNavigate();
|
||
const [personas, setPersonas] = useState<PersonaSummary[]>([]);
|
||
const [sessions, setSessions] = useState<LearnerSessionSummary[]>([]);
|
||
const [selectedCode, setSelectedCode] = useState("");
|
||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||
const [sessionLoadState, setSessionLoadState] = useState<LoadState>("loading");
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
(async () => {
|
||
setLoadState("loading");
|
||
setSessionLoadState("loading");
|
||
const [personaResult, sessionResult] = await Promise.allSettled([
|
||
personaApi.list(),
|
||
sessionApi.list(),
|
||
]);
|
||
if (!alive) return;
|
||
|
||
if (personaResult.status === "fulfilled") {
|
||
const personaRows = personaResult.value;
|
||
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
|
||
setPersonas(personaRows);
|
||
setSelectedCode((cur) =>
|
||
personaRows.some((persona) => persona.code === cur && isUsablePersona(persona))
|
||
? cur
|
||
: firstUsableCode,
|
||
);
|
||
setLoadState("ready");
|
||
} else {
|
||
console.warn("[learner-home] failed to load personas", personaResult.reason);
|
||
setPersonas([]);
|
||
setLoadState("error");
|
||
}
|
||
|
||
if (sessionResult.status === "fulfilled") {
|
||
setSessions(sessionResult.value.sessions);
|
||
setSessionLoadState("ready");
|
||
} else {
|
||
console.warn("[learner-home] failed to load sessions", sessionResult.reason);
|
||
setSessions([]);
|
||
setSessionLoadState("error");
|
||
}
|
||
})();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, []);
|
||
|
||
const usablePersonas = useMemo(() => personas.filter(isUsablePersona), [personas]);
|
||
const selected = useMemo(
|
||
() => usablePersonas.find((p) => p.code === selectedCode) ?? usablePersonas[0] ?? null,
|
||
[selectedCode, usablePersonas],
|
||
);
|
||
const selectedSessions = useMemo(
|
||
() =>
|
||
sessions
|
||
.filter((session) => session.persona_code === selected?.code)
|
||
.sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at))
|
||
.slice(0, 4),
|
||
[selected?.code, sessions],
|
||
);
|
||
const activeCount = useMemo(
|
||
() => sessions.filter((session) => session.status === "active").length,
|
||
[sessions],
|
||
);
|
||
const reviewCount = useMemo(
|
||
() => sessions.filter((session) => session.status === "ended" && session.review_ready).length,
|
||
[sessions],
|
||
);
|
||
|
||
const startPractice = () => {
|
||
if (selected && isUsablePersona(selected)) navigate(`/learn/session/${selected.code}`);
|
||
};
|
||
const historyReady = sessionLoadState === "ready";
|
||
const historyStatus =
|
||
sessionLoadState === "ready"
|
||
? `${sessions.length}회`
|
||
: sessionLoadState === "error"
|
||
? "기록 오류"
|
||
: "기록 확인 중";
|
||
const personaCatalogUnavailable = loadState === "ready" && !selected;
|
||
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
|
||
const rootClassName = [
|
||
"lh-root",
|
||
hasSessionRecords ? "has-session-records" : "",
|
||
personaCatalogUnavailable ? "has-catalog-unavailable" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return (
|
||
<AppShell contextLabel="학습 공간" hideNav bleed>
|
||
<style>{LH_CSS}</style>
|
||
<main className={rootClassName}>
|
||
<header className="lh-head">
|
||
<div className="lh-head__copy">
|
||
<Kicker>상담 연습</Kicker>
|
||
<h1 className="lh-title">오늘의 회기를 준비합니다.</h1>
|
||
<p className="lh-sub">
|
||
내담자를 고르고, 진행 중 회기는 이어가고, 끝난 회기는 리뷰에서 다시 확인합니다.
|
||
</p>
|
||
</div>
|
||
<div className="lh-head__status" aria-live="polite">
|
||
<Icon name="users" size={17} />
|
||
{loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"}
|
||
</div>
|
||
</header>
|
||
|
||
<section className="lh-workspace" aria-label="학습자 상담 연습 공간">
|
||
<div className="lh-list-pane">
|
||
<div className="lh-pane-head">
|
||
<Kicker>연습 대상</Kicker>
|
||
<span>{loadState === "ready" ? "내담자 목록" : "확인 중"}</span>
|
||
</div>
|
||
|
||
{loadState === "error" ? (
|
||
<div className="lh-error" role="alert">
|
||
<Icon name="alert" size={19} />
|
||
<div>
|
||
<b>내담자 목록을 불러오지 못했습니다.</b>
|
||
<p>잠시 뒤 다시 시도해 주세요.</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
|
||
{loadState === "loading"
|
||
? Array.from({ length: 3 }).map((_, i) => (
|
||
<div className="lh-persona is-skel" key={i} aria-hidden="true">
|
||
<span className="lh-persona__mark lh-skel__box" />
|
||
<span className="lh-persona__body">
|
||
<span className="lh-skel__line lh-skel__line--name" />
|
||
<span className="lh-skel__line lh-skel__line--meta" />
|
||
<span className="lh-skel__line lh-skel__line--sum" />
|
||
<span className="lh-skel__line lh-skel__line--sum2" />
|
||
</span>
|
||
</div>
|
||
))
|
||
: personas.map((persona) => {
|
||
const usablePersona = isUsablePersona(persona);
|
||
const selectedPersona = usablePersona && persona.code === selected?.code;
|
||
const personaMeta =
|
||
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
|
||
" · " +
|
||
theoryLine(persona);
|
||
return (
|
||
<button
|
||
key={persona.code}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={selectedPersona}
|
||
aria-disabled={!usablePersona}
|
||
disabled={!usablePersona}
|
||
className={`lh-persona ${selectedPersona ? "is-selected" : ""}`}
|
||
onClick={() => {
|
||
if (usablePersona) setSelectedCode(persona.code);
|
||
}}
|
||
>
|
||
<span className="lh-persona__mark">{persona.code}</span>
|
||
<span className="lh-persona__body">
|
||
<span className="lh-persona__name">{persona.display_name}</span>
|
||
<span className="lh-persona__meta">
|
||
{usablePersona ? personaMeta : `${personaMeta} · 사용 불가`}
|
||
</span>
|
||
<span className="lh-persona__summary">
|
||
{usablePersona
|
||
? persona.presenting_summary
|
||
: unavailablePersonaMessage(persona)}
|
||
</span>
|
||
</span>
|
||
{selectedPersona ? (
|
||
<span className="lh-persona__check" aria-hidden="true">
|
||
<Icon name="check" size={15} strokeWidth={2.4} />
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<section className="lh-preview" aria-labelledby="lh-preview-title">
|
||
<div className="lh-preview__main">
|
||
<div className="lh-preview__hero">
|
||
<div className="lh-preview__identity">
|
||
<div className="lh-avatar" aria-hidden="true">
|
||
{personaCatalogUnavailable ? "!" : personaInitial(selected)}
|
||
</div>
|
||
<div className="lh-preview__copy">
|
||
<span className="lh-preview__code">
|
||
{personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")}
|
||
</span>
|
||
<h2 id="lh-preview-title">
|
||
{personaCatalogUnavailable
|
||
? "사용 가능한 내담자가 없습니다."
|
||
: (selected?.display_name ?? "내담자 정보를 불러오는 중")}
|
||
</h2>
|
||
<p>
|
||
{personaCatalogUnavailable
|
||
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
|
||
: demographicsLine(selected)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lh-actions">
|
||
<span className="lh-actions__note">
|
||
회기 종료 후 저장된 대화 기록으로 리뷰가 생성됩니다.
|
||
</span>
|
||
<Button
|
||
size="lg"
|
||
onClick={startPractice}
|
||
disabled={!selected}
|
||
trailing={<Icon name="chevron-right" size={18} />}
|
||
>
|
||
새 회기 시작
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lh-summary">
|
||
<Kicker>{personaCatalogUnavailable ? "카탈로그 상태" : "주호소"}</Kicker>
|
||
<p>
|
||
{personaCatalogUnavailable
|
||
? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다. 카탈로그가 복구되면 선택할 수 있습니다."
|
||
: (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")}
|
||
</p>
|
||
</div>
|
||
|
||
<dl className="lh-facts">
|
||
<div>
|
||
<dt>난도</dt>
|
||
<dd>{difficultyLine(selected)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>접근</dt>
|
||
<dd>{theoryLine(selected)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>음성</dt>
|
||
<dd>{selected?.voice_preset ?? "기본"}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
|
||
<section
|
||
className={`lh-activity ${
|
||
historyReady && selectedSessions.length === 0 ? "is-empty" : ""
|
||
}`}
|
||
aria-label="연습 기록"
|
||
>
|
||
<div className="lh-activity__stats">
|
||
<span>
|
||
<b>{historyReady ? activeCount : "–"}</b>
|
||
<small>진행 중</small>
|
||
</span>
|
||
<span>
|
||
<b>{historyReady ? reviewCount : "–"}</b>
|
||
<small>리뷰 가능</small>
|
||
</span>
|
||
<span>
|
||
<b>{historyReady ? sessions.length : "–"}</b>
|
||
<small>누적 회기</small>
|
||
</span>
|
||
</div>
|
||
<div className="lh-activity__recent">
|
||
<div className="lh-activity__head">
|
||
<Kicker>기존 회기</Kicker>
|
||
<span>{selected ? `${selected.code} · 전체 ${historyReady ? sessions.length : "–"}회` : "-"}</span>
|
||
</div>
|
||
{sessionLoadState === "loading" ? (
|
||
<p>저장된 연습 기록을 확인하고 있습니다.</p>
|
||
) : sessionLoadState === "error" ? (
|
||
<div className="lh-activity__unavailable" role="status">
|
||
<Icon name="alert" size={16} />
|
||
<span>연습 기록을 불러오지 못해 횟수를 표시하지 않습니다.</span>
|
||
</div>
|
||
) : personaCatalogUnavailable ? (
|
||
<div className="lh-activity__unavailable" role="status">
|
||
<Icon name="alert" size={16} />
|
||
<span>사용 가능한 내담자가 없어 기존 회기를 선택할 수 없습니다.</span>
|
||
</div>
|
||
) : selectedSessions.length > 0 ? (
|
||
<ul>
|
||
{selectedSessions.map((session) => (
|
||
<li key={session.session_id}>
|
||
<span>
|
||
<b>{sessionStatusLabel(session)}</b>
|
||
<small>{sessionDateLabel(session.started_at)}</small>
|
||
</span>
|
||
<em>
|
||
{session.turn_count}턴 · {session.stage}
|
||
</em>
|
||
<span className="lh-session-actions">
|
||
{session.status === "active" ? (
|
||
<button
|
||
type="button"
|
||
className="lh-review-link"
|
||
onClick={() => navigate(`/learn/session/${session.session_id}`)}
|
||
>
|
||
이어하기
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="lh-review-link"
|
||
onClick={() =>
|
||
navigate(`/learn/session/${session.session_id}/review`)
|
||
}
|
||
>
|
||
{session.review_ready ? "리뷰" : "기록"}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="lh-review-link lh-review-link--ghost"
|
||
onClick={() => navigate(`/learn/session/${session.persona_code}`)}
|
||
>
|
||
다시 연습
|
||
</button>
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div className="lh-activity__empty" role="status">
|
||
<Icon name="session" size={18} />
|
||
<div>
|
||
<b>저장된 기존 회기가 없습니다.</b>
|
||
<p>첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
</section>
|
||
</section>
|
||
</main>
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
const LH_CSS = `
|
||
.lh-root{
|
||
width:min(100%,1480px);
|
||
min-height:calc(100dvh - var(--topbar-h));
|
||
margin:0 auto;
|
||
position:relative;
|
||
isolation:isolate;
|
||
display:grid;
|
||
grid-template-rows:auto minmax(0,1fr);
|
||
align-content:start;
|
||
gap:clamp(12px,1.6vw,20px);
|
||
padding:clamp(14px,2vw,28px);
|
||
background:var(--bg-app);
|
||
overflow:hidden;
|
||
}
|
||
.lh-root::before{
|
||
content:"";
|
||
position:absolute;
|
||
z-index:-1;
|
||
inset:-120px 0 auto auto;
|
||
width:min(720px,58vw);
|
||
height:520px;
|
||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||
opacity:.07;
|
||
filter:saturate(.8);
|
||
pointer-events:none;
|
||
}
|
||
.lh-head{
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
gap:var(--sp-4);
|
||
min-width:0;
|
||
}
|
||
.lh-head__copy{min-width:0;}
|
||
.lh-head__status{
|
||
flex:none;
|
||
min-height:34px;
|
||
display:inline-flex;
|
||
align-items:center;
|
||
gap:8px;
|
||
padding:0 12px;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface);
|
||
color:var(--text-body);
|
||
font-family:var(--font-num);
|
||
font-size:var(--fs-xs);
|
||
font-weight:750;
|
||
}
|
||
.lh-title{
|
||
margin:5px 0 0;
|
||
font-size:clamp(22px,2.4vw,30px);
|
||
font-weight:740;
|
||
letter-spacing:0;
|
||
line-height:1.18;
|
||
color:var(--text-strong);
|
||
}
|
||
.lh-sub{
|
||
margin:6px 0 0;
|
||
max-width:760px;
|
||
color:var(--text-body);
|
||
font-size:var(--fs-sm);
|
||
line-height:1.45;
|
||
}
|
||
.lh-workspace{
|
||
min-width:0;
|
||
display:grid;
|
||
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
|
||
gap:clamp(14px,1.8vw,24px);
|
||
align-items:start;
|
||
}
|
||
.lh-list-pane,
|
||
.lh-preview{
|
||
min-width:0;
|
||
}
|
||
.lh-list-pane{
|
||
position:sticky;
|
||
top:calc(var(--topbar-h) + 18px);
|
||
max-height:calc(100dvh - var(--topbar-h) - 36px);
|
||
display:grid;
|
||
grid-template-rows:auto minmax(0,1fr);
|
||
padding:var(--sp-4);
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface);
|
||
box-shadow:var(--shadow-sm);
|
||
overflow:hidden;
|
||
}
|
||
.lh-list-pane::after{
|
||
content:"";
|
||
position:absolute;
|
||
inset:auto -48px -68px auto;
|
||
width:180px;
|
||
height:180px;
|
||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||
opacity:.05;
|
||
pointer-events:none;
|
||
}
|
||
.lh-pane-head{
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
gap:var(--sp-3);
|
||
margin-bottom:var(--sp-3);
|
||
}
|
||
.lh-pane-head span{
|
||
font-size:var(--fs-xs);
|
||
font-weight:700;
|
||
color:var(--text-muted);
|
||
font-family:var(--font-num);
|
||
white-space:nowrap;
|
||
}
|
||
.lh-personas{
|
||
min-height:0;
|
||
display:grid;
|
||
grid-auto-rows:auto;
|
||
align-content:start;
|
||
gap:8px;
|
||
overflow:auto;
|
||
scrollbar-gutter:stable;
|
||
padding-right:3px;
|
||
}
|
||
.lh-persona{
|
||
position:relative;
|
||
min-width:0;
|
||
min-height:92px;
|
||
display:grid;
|
||
grid-template-columns:44px minmax(0,1fr);
|
||
gap:11px;
|
||
align-items:start;
|
||
padding:12px 38px 12px 12px;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface-2);
|
||
color:var(--text-body);
|
||
text-align:left;
|
||
cursor:pointer;
|
||
transition:background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
|
||
}
|
||
.lh-persona:hover{border-color:var(--border-strong);}
|
||
.lh-persona:disabled{
|
||
cursor:not-allowed;
|
||
opacity:.68;
|
||
}
|
||
.lh-persona.is-selected{
|
||
border-color:var(--accent);
|
||
background:var(--accent-tint);
|
||
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent);
|
||
}
|
||
.lh-persona__mark{
|
||
width:40px;
|
||
height:40px;
|
||
display:grid;
|
||
place-items:center;
|
||
color:var(--text-strong);
|
||
background:var(--bg-surface);
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
font-family:var(--font-num);
|
||
font-size:12px;
|
||
font-weight:800;
|
||
}
|
||
.lh-persona__body{
|
||
min-width:0;
|
||
display:grid;
|
||
gap:5px;
|
||
}
|
||
.lh-persona__name{
|
||
color:var(--text-strong);
|
||
font-size:14.5px;
|
||
font-weight:760;
|
||
line-height:1.25;
|
||
overflow:hidden;
|
||
display:-webkit-box;
|
||
-webkit-line-clamp:1;
|
||
-webkit-box-orient:vertical;
|
||
}
|
||
.lh-persona__meta{
|
||
color:var(--text-muted);
|
||
font-size:12px;
|
||
line-height:1.45;
|
||
overflow:hidden;
|
||
text-overflow:ellipsis;
|
||
white-space:nowrap;
|
||
}
|
||
.lh-persona__summary{
|
||
min-width:0;
|
||
color:var(--text-body);
|
||
font-size:12.5px;
|
||
line-height:1.45;
|
||
overflow:hidden;
|
||
display:-webkit-box;
|
||
-webkit-line-clamp:2;
|
||
-webkit-box-orient:vertical;
|
||
}
|
||
.lh-persona__check{
|
||
position:absolute;
|
||
top:10px;
|
||
right:10px;
|
||
width:22px;
|
||
height:22px;
|
||
display:grid;
|
||
place-items:center;
|
||
border-radius:50%;
|
||
background:var(--accent);
|
||
color:var(--text-on-accent);
|
||
}
|
||
.lh-persona.is-skel{
|
||
pointer-events:none;
|
||
}
|
||
/* 실제 카드 구조(마크 + 이름/메타/요약 2줄)를 모사한 플레이스홀더 셰이머 */
|
||
.lh-skel__box,
|
||
.lh-skel__line{
|
||
background:linear-gradient(90deg,var(--bg-surface-2),var(--neutral-100),var(--bg-surface-2));
|
||
background-size:180% 100%;
|
||
animation:lhSkel 1.1s ease-in-out infinite;
|
||
}
|
||
.lh-skel__line{
|
||
height:11px;
|
||
border-radius:6px;
|
||
align-self:center;
|
||
}
|
||
.lh-skel__line--name{width:62%;height:13px;}
|
||
.lh-skel__line--meta{width:44%;height:10px;}
|
||
.lh-skel__line--sum{width:92%;}
|
||
.lh-skel__line--sum2{width:76%;}
|
||
.lh-preview{
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr);
|
||
gap:clamp(14px,1.8vw,24px);
|
||
align-content:start;
|
||
align-items:start;
|
||
}
|
||
.lh-preview__main{
|
||
min-width:0;
|
||
position:relative;
|
||
display:grid;
|
||
gap:var(--sp-4);
|
||
align-content:start;
|
||
padding:var(--sp-5);
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface);
|
||
box-shadow:var(--shadow-sm);
|
||
overflow:hidden;
|
||
}
|
||
.lh-preview__main::after{
|
||
content:"";
|
||
position:absolute;
|
||
inset:auto -180px -210px auto;
|
||
width:520px;
|
||
height:360px;
|
||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||
opacity:.08;
|
||
filter:saturate(.82);
|
||
pointer-events:none;
|
||
}
|
||
.lh-preview__main > *{
|
||
position:relative;
|
||
z-index:1;
|
||
}
|
||
.lh-preview__hero{
|
||
min-width:0;
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr) minmax(220px,auto);
|
||
gap:var(--sp-4);
|
||
align-items:start;
|
||
}
|
||
@media (min-width:1400px) and (max-width:1559px){
|
||
.lh-preview__hero{
|
||
grid-template-columns:minmax(0,1fr);
|
||
}
|
||
.lh-actions{
|
||
justify-items:start;
|
||
}
|
||
.lh-actions__note{
|
||
text-align:left;
|
||
}
|
||
}
|
||
.lh-preview__identity{
|
||
min-width:0;
|
||
display:grid;
|
||
grid-template-columns:auto minmax(0,1fr);
|
||
align-items:start;
|
||
gap:var(--sp-4);
|
||
}
|
||
.lh-avatar{
|
||
width:64px;
|
||
height:64px;
|
||
display:grid;
|
||
place-items:center;
|
||
border-radius:50%;
|
||
background:var(--clay-tint);
|
||
color:var(--clay-deep);
|
||
font-size:26px;
|
||
font-weight:780;
|
||
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--clay) 22%,transparent);
|
||
}
|
||
.lh-preview__copy{min-width:0;}
|
||
.lh-preview__code{
|
||
display:inline-flex;
|
||
align-items:center;
|
||
min-height:24px;
|
||
padding:0 9px;
|
||
border-radius:var(--radius-sm);
|
||
background:var(--accent-tint);
|
||
color:var(--accent-deep);
|
||
font-family:var(--font-num);
|
||
font-size:12px;
|
||
font-weight:800;
|
||
}
|
||
.lh-preview h2{
|
||
margin:8px 0 0;
|
||
color:var(--text-strong);
|
||
font-size:clamp(22px,2.4vw,30px);
|
||
line-height:1.2;
|
||
letter-spacing:0;
|
||
word-break:keep-all;
|
||
overflow-wrap:anywhere;
|
||
overflow:hidden;
|
||
display:-webkit-box;
|
||
-webkit-line-clamp:2;
|
||
-webkit-box-orient:vertical;
|
||
}
|
||
.lh-preview__copy p{
|
||
margin:7px 0 0;
|
||
color:var(--text-muted);
|
||
font-size:var(--fs-sm);
|
||
line-height:1.45;
|
||
word-break:keep-all;
|
||
overflow-wrap:anywhere;
|
||
}
|
||
.lh-summary{
|
||
min-width:0;
|
||
padding:var(--sp-4);
|
||
border-radius:var(--radius);
|
||
background:
|
||
linear-gradient(180deg,color-mix(in srgb,var(--clay-tint) 48%,var(--bg-surface-2)),var(--bg-surface-2));
|
||
}
|
||
.lh-summary p{
|
||
margin:8px 0 0;
|
||
color:var(--text-body);
|
||
font-size:clamp(15px,1.2vw,18px);
|
||
line-height:1.52;
|
||
overflow:hidden;
|
||
display:-webkit-box;
|
||
-webkit-line-clamp:4;
|
||
-webkit-box-orient:vertical;
|
||
}
|
||
.lh-facts{
|
||
display:grid;
|
||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||
gap:10px;
|
||
margin:0;
|
||
}
|
||
.lh-facts div{
|
||
min-width:0;
|
||
min-height:64px;
|
||
padding:12px;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface);
|
||
}
|
||
.lh-facts dt{
|
||
margin:0;
|
||
color:var(--text-muted);
|
||
font-size:12px;
|
||
font-weight:720;
|
||
}
|
||
.lh-facts dd{
|
||
margin:7px 0 0;
|
||
color:var(--text-strong);
|
||
font-size:14px;
|
||
font-weight:720;
|
||
line-height:1.3;
|
||
overflow:hidden;
|
||
text-overflow:ellipsis;
|
||
white-space:nowrap;
|
||
}
|
||
.lh-activity{
|
||
display:grid;
|
||
gap:var(--sp-4);
|
||
padding:var(--sp-5);
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface);
|
||
box-shadow:var(--shadow-sm);
|
||
}
|
||
.lh-activity__stats{
|
||
display:grid;
|
||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||
gap:8px;
|
||
}
|
||
.lh-activity__stats span,
|
||
.lh-activity__recent{
|
||
min-width:0;
|
||
}
|
||
.lh-activity__stats span{
|
||
min-height:58px;
|
||
display:grid;
|
||
align-content:center;
|
||
gap:4px;
|
||
padding:10px;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface-2);
|
||
}
|
||
.lh-activity__stats b{
|
||
color:var(--text-strong);
|
||
font-family:var(--font-num);
|
||
font-size:22px;
|
||
line-height:1;
|
||
}
|
||
.lh-activity__stats small{
|
||
color:var(--text-muted);
|
||
font-size:12px;
|
||
font-weight:720;
|
||
}
|
||
.lh-activity__recent{
|
||
min-height:0;
|
||
}
|
||
.lh-activity__head{
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
gap:var(--sp-3);
|
||
margin-bottom:10px;
|
||
}
|
||
.lh-activity__head span{
|
||
color:var(--text-muted);
|
||
font-family:var(--font-num);
|
||
font-size:12px;
|
||
font-weight:800;
|
||
white-space:nowrap;
|
||
}
|
||
.lh-activity__recent ul{
|
||
list-style:none;
|
||
margin:0;
|
||
padding:0;
|
||
display:grid;
|
||
gap:8px;
|
||
}
|
||
.lh-activity__recent li{
|
||
min-width:0;
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr) auto;
|
||
gap:6px 10px;
|
||
align-items:center;
|
||
padding:10px 0;
|
||
border-top:1px solid var(--hair);
|
||
}
|
||
.lh-activity__recent li:first-child{border-top:0;padding-top:0;}
|
||
.lh-activity__recent li > span:first-child{
|
||
min-width:0;
|
||
display:grid;
|
||
gap:2px;
|
||
}
|
||
.lh-activity__recent b{
|
||
color:var(--text-strong);
|
||
font-size:13px;
|
||
}
|
||
.lh-activity__recent small,
|
||
.lh-activity__recent p{
|
||
color:var(--text-muted);
|
||
font-size:12px;
|
||
}
|
||
.lh-activity__recent p{margin:0;line-height:1.45;}
|
||
.lh-activity__recent em{
|
||
grid-column:1;
|
||
color:var(--text-body);
|
||
font-family:var(--font-num);
|
||
font-size:12px;
|
||
font-style:normal;
|
||
}
|
||
.lh-session-actions{
|
||
grid-column:2;
|
||
grid-row:1 / span 2;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:flex-end;
|
||
gap:6px;
|
||
}
|
||
.lh-activity__unavailable{
|
||
display:flex;
|
||
align-items:center;
|
||
gap:8px;
|
||
color:var(--warn-text);
|
||
font-size:12px;
|
||
line-height:1.45;
|
||
}
|
||
.lh-activity__empty{
|
||
display:grid;
|
||
grid-template-columns:auto minmax(0,1fr);
|
||
gap:10px;
|
||
align-items:start;
|
||
padding:12px;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius);
|
||
background:var(--bg-surface-2);
|
||
color:var(--text-body);
|
||
}
|
||
.lh-activity__empty svg{
|
||
margin-top:2px;
|
||
color:var(--accent-deep);
|
||
}
|
||
.lh-activity__empty b{
|
||
display:block;
|
||
color:var(--text-strong);
|
||
font-size:13px;
|
||
}
|
||
.lh-activity__empty p{
|
||
margin:4px 0 0;
|
||
color:var(--text-muted);
|
||
font-size:12px;
|
||
line-height:1.45;
|
||
}
|
||
.lh-review-link{
|
||
flex:none;
|
||
border:1px solid var(--border-subtle);
|
||
border-radius:var(--radius-sm);
|
||
background:var(--bg-surface);
|
||
color:var(--accent-deep);
|
||
font-family:var(--font-sans);
|
||
font-size:12px;
|
||
font-weight:720;
|
||
min-height:30px;
|
||
padding:5px 9px;
|
||
cursor:pointer;
|
||
white-space:nowrap;
|
||
}
|
||
.lh-review-link:hover{background:var(--accent-tint);}
|
||
.lh-review-link--ghost{
|
||
color:var(--text-body);
|
||
}
|
||
.lh-review-link--ghost:hover{color:var(--accent-deep);}
|
||
.lh-actions{
|
||
min-width:0;
|
||
display:grid;
|
||
justify-items:end;
|
||
gap:10px;
|
||
}
|
||
.lh-actions__note{
|
||
display:block;
|
||
min-width:0;
|
||
max-width:260px;
|
||
color:var(--text-muted);
|
||
font-size:12.5px;
|
||
line-height:1.45;
|
||
text-align:right;
|
||
word-break:keep-all;
|
||
text-wrap:balance;
|
||
}
|
||
.lh-error{
|
||
display:flex;
|
||
align-items:flex-start;
|
||
gap:var(--sp-3);
|
||
color:var(--crit-text);
|
||
background:var(--crit-tint);
|
||
border-radius:var(--radius);
|
||
padding:var(--sp-4);
|
||
}
|
||
.lh-error b{display:block;}
|
||
.lh-error p{margin:4px 0 0;font-size:var(--fs-sm);line-height:1.5;}
|
||
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
|
||
@media (min-width:1400px){
|
||
.lh-preview{
|
||
grid-template-columns:minmax(440px,1fr) minmax(340px,400px);
|
||
column-gap:var(--sp-5);
|
||
}
|
||
.lh-activity{
|
||
position:sticky;
|
||
top:calc(var(--topbar-h) + 18px);
|
||
}
|
||
.lh-activity__stats{
|
||
grid-template-columns:1fr;
|
||
}
|
||
}
|
||
@media (max-width:1080px){
|
||
.lh-workspace{
|
||
grid-template-columns:1fr;
|
||
}
|
||
.lh-list-pane{
|
||
position:relative;
|
||
top:auto;
|
||
max-height:none;
|
||
overflow:visible;
|
||
}
|
||
.lh-root::before{
|
||
width:560px;
|
||
height:420px;
|
||
opacity:.055;
|
||
}
|
||
.lh-personas{
|
||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||
grid-auto-rows:auto;
|
||
overflow:visible;
|
||
padding-right:0;
|
||
}
|
||
.lh-preview{
|
||
grid-template-columns:1fr;
|
||
}
|
||
.lh-activity__stats{
|
||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||
}
|
||
}
|
||
@media (max-width:760px){
|
||
.lh-root{
|
||
padding:12px;
|
||
gap:12px;
|
||
}
|
||
.lh-root::before,
|
||
.lh-preview__main::after,
|
||
.lh-list-pane::after{
|
||
display:none;
|
||
}
|
||
.lh-head{
|
||
align-items:flex-start;
|
||
}
|
||
.lh-workspace{
|
||
display:flex;
|
||
flex-direction:column;
|
||
gap:10px;
|
||
}
|
||
.lh-list-pane{
|
||
padding:12px;
|
||
}
|
||
.lh-pane-head{margin-bottom:10px;}
|
||
.lh-personas{
|
||
display:flex;
|
||
grid-template-columns:none;
|
||
gap:8px;
|
||
overflow-x:auto;
|
||
overflow-y:hidden;
|
||
padding-right:0;
|
||
padding-bottom:2px;
|
||
scrollbar-gutter:auto;
|
||
}
|
||
.lh-persona{
|
||
flex:0 0 min(82vw,320px);
|
||
min-height:64px;
|
||
grid-template-columns:38px minmax(0,1fr);
|
||
align-items:center;
|
||
gap:10px;
|
||
padding:10px 38px 10px 10px;
|
||
}
|
||
.lh-persona__mark{width:34px;height:34px;}
|
||
.lh-persona__name{font-size:13.5px;-webkit-line-clamp:1;}
|
||
.lh-persona__meta{font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.lh-persona__summary{display:none;}
|
||
.lh-persona__check{top:50%;right:10px;transform:translateY(-50%);}
|
||
.lh-preview{
|
||
gap:12px;
|
||
}
|
||
.lh-preview__main,
|
||
.lh-activity{
|
||
padding:14px;
|
||
}
|
||
.lh-preview__hero{
|
||
grid-template-columns:1fr;
|
||
gap:12px;
|
||
}
|
||
.lh-preview__identity{grid-template-columns:54px minmax(0,1fr);gap:12px;}
|
||
.lh-avatar{width:54px;height:54px;font-size:22px;}
|
||
.lh-preview h2{margin-top:6px;font-size:20px;-webkit-line-clamp:2;}
|
||
.lh-preview__copy p{margin-top:5px;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.lh-actions{
|
||
justify-items:stretch;
|
||
}
|
||
.lh-actions__note{
|
||
max-width:none;
|
||
text-align:left;
|
||
}
|
||
.lh-actions .vg-btn{
|
||
width:100%;
|
||
justify-content:center;
|
||
}
|
||
.lh-summary{padding:12px;}
|
||
.lh-summary p{margin-top:6px;font-size:14px;line-height:1.45;-webkit-line-clamp:2;}
|
||
.lh-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}
|
||
.lh-facts div{min-height:58px;padding:10px;}
|
||
.lh-facts dt{font-size:11px;}
|
||
.lh-facts dd{font-size:12px;}
|
||
.lh-activity{gap:12px;}
|
||
.lh-activity__stats{grid-template-columns:repeat(3,minmax(0,1fr));}
|
||
.lh-activity__stats span{
|
||
min-height:46px;
|
||
display:grid;
|
||
place-items:center;
|
||
gap:2px;
|
||
padding:8px;
|
||
}
|
||
.lh-activity__stats b{font-size:17px;}
|
||
.lh-activity__stats small{font-size:11px;}
|
||
.lh-session-actions{gap:4px;}
|
||
.lh-review-link{padding:4px 7px;}
|
||
}
|
||
@media (max-width:560px){
|
||
.lh-head__status{display:none;}
|
||
.lh-head .vg-kicker,
|
||
.lh-pane-head .vg-kicker,
|
||
.lh-summary .vg-kicker,
|
||
.lh-activity__head .vg-kicker{
|
||
font-size:11px;
|
||
gap:6px;
|
||
}
|
||
.lh-title{margin-top:3px;font-size:20px;line-height:1.18;}
|
||
.lh-sub{
|
||
margin-top:4px;
|
||
font-size:12.5px;
|
||
line-height:1.35;
|
||
}
|
||
.lh-head{gap:6px;}
|
||
.lh-pane-head{margin-bottom:5px;}
|
||
.lh-persona{
|
||
flex:0 0 min(78vw,300px);
|
||
min-height:56px;
|
||
width:min(78vw,300px);
|
||
grid-template-columns:30px minmax(0,1fr);
|
||
padding:7px 34px 7px 7px;
|
||
}
|
||
.lh-persona__mark{width:28px;height:28px;font-size:11px;}
|
||
.lh-persona__name{font-size:13px;}
|
||
.lh-persona__meta{font-size:11px;}
|
||
.lh-preview{gap:10px;}
|
||
.lh-preview__main{gap:10px;}
|
||
.lh-preview__identity{grid-template-columns:38px minmax(0,1fr);gap:9px;}
|
||
.lh-avatar{width:38px;height:38px;font-size:17px;}
|
||
.lh-preview h2{margin-top:4px;font-size:17px;line-height:1.2;}
|
||
.lh-preview__code{min-height:20px;font-size:10.5px;padding:0 8px;}
|
||
.lh-preview__copy p{margin-top:4px;font-size:11.5px;}
|
||
.lh-actions__note{display:none;}
|
||
.lh-summary{padding:10px;}
|
||
.lh-summary p{font-size:12.5px;line-height:1.38;-webkit-line-clamp:2;}
|
||
.lh-facts{grid-template-columns:1fr;gap:6px;}
|
||
.lh-facts div{
|
||
min-height:0;
|
||
display:flex;
|
||
align-items:center;
|
||
justify-content:space-between;
|
||
gap:6px;
|
||
padding:8px 10px;
|
||
}
|
||
.lh-facts dt{font-size:10.5px;}
|
||
.lh-facts dd{
|
||
margin-top:0;
|
||
font-size:11.5px;
|
||
white-space:nowrap;
|
||
overflow:hidden;
|
||
display:block;
|
||
text-overflow:ellipsis;
|
||
}
|
||
.lh-activity{padding:12px;gap:10px;}
|
||
.lh-activity__stats{gap:6px;}
|
||
.lh-activity__stats span{
|
||
min-height:40px;
|
||
padding:6px 4px;
|
||
}
|
||
.lh-activity__stats b{font-size:16px;}
|
||
.lh-activity__stats small{font-size:10.5px;}
|
||
.lh-activity__head{margin-bottom:6px;}
|
||
.lh-activity__empty{
|
||
padding:8px;
|
||
gap:8px;
|
||
}
|
||
.lh-activity__empty b{font-size:12px;}
|
||
.lh-activity__empty p{
|
||
margin-top:2px;
|
||
font-size:11px;
|
||
line-height:1.35;
|
||
overflow:hidden;
|
||
display:-webkit-box;
|
||
-webkit-line-clamp:1;
|
||
-webkit-box-orient:vertical;
|
||
}
|
||
.lh-activity__recent li{
|
||
display:grid;
|
||
grid-template-columns:minmax(0,1fr) auto;
|
||
gap:6px 10px;
|
||
}
|
||
.lh-activity__recent li em{display:none;}
|
||
.lh-session-actions{grid-column:2;grid-row:1 / span 2;flex-direction:column;align-items:stretch;}
|
||
.lh-actions .vg-btn{
|
||
min-height:40px;
|
||
font-size:14px;
|
||
padding:0 12px;
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion:reduce){
|
||
.lh-skel__box,
|
||
.lh-skel__line{animation:none;}
|
||
}
|
||
`;
|