feat: standardize card library as treegrid widget
Check card layouts / check (push) Has been cancelled
Check card layouts / check (push) Has been cancelled
This commit is contained in:
+224
-115
@@ -4594,6 +4594,87 @@ function SidePanelDock({
|
||||
);
|
||||
}
|
||||
|
||||
type CardLibraryTreeKind = "subject" | "level" | "series" | "card" | "task";
|
||||
|
||||
function CardLibraryTreeRow({
|
||||
id,
|
||||
depth,
|
||||
kind,
|
||||
title,
|
||||
label,
|
||||
refValue,
|
||||
stateValue,
|
||||
stateKind,
|
||||
hasChildren = false,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
href,
|
||||
current = false,
|
||||
unavailable = false,
|
||||
onNavigate,
|
||||
}: {
|
||||
id: string;
|
||||
depth: number;
|
||||
kind: CardLibraryTreeKind;
|
||||
title: string;
|
||||
label: React.ReactNode;
|
||||
refValue: React.ReactNode;
|
||||
stateValue: React.ReactNode;
|
||||
stateKind?: string;
|
||||
hasChildren?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
href?: string;
|
||||
current?: boolean;
|
||||
unavailable?: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const nodeLabel = href ? (
|
||||
<a
|
||||
className="card-library-tree-link"
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
aria-current={current ? "page" : undefined}
|
||||
title={current ? "Pokaż bieżącą kartę" : `Otwórz: ${title}`}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="card-library-tree-label" title={title}>{label}</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`card-library-treegrid-row is-${kind}${current ? " is-current" : ""}${unavailable ? " is-unavailable" : ""}`}
|
||||
role="row"
|
||||
aria-level={depth + 1}
|
||||
aria-expanded={hasChildren ? expanded : undefined}
|
||||
aria-disabled={unavailable || undefined}
|
||||
data-node-id={id}
|
||||
style={{ "--card-library-depth": depth } as React.CSSProperties}
|
||||
>
|
||||
<span className="card-library-tree-title" role="gridcell">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="card-library-tree-expander"
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={`${expanded ? "Zwiń" : "Rozwiń"}: ${title}`}
|
||||
>
|
||||
{expanded ? "▾" : "▸"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="card-library-tree-expander is-empty" aria-hidden="true" />
|
||||
)}
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<span role="gridcell">{refValue}</span>
|
||||
<span role="gridcell">
|
||||
<b className="card-library-tree-state" data-state={stateKind}>{stateValue}</b>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardLibraryPanel({
|
||||
library,
|
||||
onNavigate,
|
||||
@@ -4626,141 +4707,169 @@ function CardLibraryPanel({
|
||||
),
|
||||
0,
|
||||
);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => {
|
||||
const initial = new Set<string>();
|
||||
library.subjects.forEach(subject => {
|
||||
const subjectKey = `subject:${subject.id}`;
|
||||
if (subject.current) initial.add(subjectKey);
|
||||
subject.levels.forEach(level => {
|
||||
const levelKey = `${subjectKey}:level:${level.id}`;
|
||||
if (level.current) initial.add(levelKey);
|
||||
level.series.forEach(series => {
|
||||
const seriesKey = `${levelKey}:series:${series.id}`;
|
||||
if (series.current) initial.add(seriesKey);
|
||||
series.cards.forEach(card => {
|
||||
if (card.current && (card.tasks?.length ?? 0) > 0) {
|
||||
initial.add(`${seriesKey}:card:${card.id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const isExpanded = (id: string) => expandedNodes.has(id);
|
||||
const toggle = (id: string) => {
|
||||
setExpandedNodes(current => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="card-library-panel">
|
||||
<header className="card-library-header">
|
||||
<div>
|
||||
<small>{library.workspace || "STEM"} · KATALOG</small>
|
||||
<h2 id="card-library-title">Serie i karty</h2>
|
||||
<p>{seriesCount} serii · {availableCount}/{cardCount} kart online</p>
|
||||
<div
|
||||
className="card-library-treegrid"
|
||||
role="treegrid"
|
||||
aria-label="Serie, karty i taski"
|
||||
aria-colcount={3}
|
||||
>
|
||||
<div className="card-library-treegrid-row card-library-treegrid-head" role="row">
|
||||
<span role="columnheader">DRZEWO</span>
|
||||
<span role="columnheader">REF</span>
|
||||
<span role="columnheader">STATUS</span>
|
||||
</div>
|
||||
</header>
|
||||
<nav className="card-library-tree" aria-label="Drzewo serii i kart">
|
||||
{library.subjects.map(subject => (
|
||||
<details
|
||||
className="card-library-subject"
|
||||
key={subject.id}
|
||||
open={subject.current || undefined}
|
||||
>
|
||||
<summary>
|
||||
<span className="card-library-branch" aria-hidden="true">▾</span>
|
||||
<span>
|
||||
<strong>{subject.title}</strong>
|
||||
<small>PRZEDMIOT</small>
|
||||
</span>
|
||||
<output>{subject.levels.length}</output>
|
||||
</summary>
|
||||
<div className="card-library-level-list">
|
||||
{subject.levels.map(level => (
|
||||
<details
|
||||
className="card-library-level"
|
||||
key={level.id}
|
||||
open={level.current || undefined}
|
||||
>
|
||||
<summary>
|
||||
<span className="card-library-branch" aria-hidden="true">▾</span>
|
||||
<strong>{level.title}</strong>
|
||||
<output>{level.series.length}</output>
|
||||
</summary>
|
||||
<div className="card-library-series-list">
|
||||
{level.series.map(series => (
|
||||
<details
|
||||
className="card-library-series"
|
||||
key={`${level.id}:${series.id}`}
|
||||
open={series.current || undefined}
|
||||
>
|
||||
<summary>
|
||||
<span className="card-library-branch" aria-hidden="true">▾</span>
|
||||
<span>
|
||||
<strong>{series.title}</strong>
|
||||
{series.role === "reference" && <small>REFERENCJA</small>}
|
||||
</span>
|
||||
<output>{series.cards.length}</output>
|
||||
</summary>
|
||||
{series.cards.length > 0 && (
|
||||
<ul className="card-library-cards">
|
||||
{series.cards.map((card, cardIndex) => {
|
||||
{library.subjects.map(subject => {
|
||||
const subjectKey = `subject:${subject.id}`;
|
||||
return (
|
||||
<React.Fragment key={subjectKey}>
|
||||
<CardLibraryTreeRow
|
||||
id={subjectKey}
|
||||
depth={0}
|
||||
kind="subject"
|
||||
title={subject.title}
|
||||
label={<strong>{subject.title}</strong>}
|
||||
refValue="PRZEDMIOT"
|
||||
stateValue={`${subject.levels.length} POZ.`}
|
||||
hasChildren={subject.levels.length > 0}
|
||||
expanded={isExpanded(subjectKey)}
|
||||
onToggle={() => toggle(subjectKey)}
|
||||
/>
|
||||
{isExpanded(subjectKey) && subject.levels.map(level => {
|
||||
const levelKey = `${subjectKey}:level:${level.id}`;
|
||||
return (
|
||||
<React.Fragment key={levelKey}>
|
||||
<CardLibraryTreeRow
|
||||
id={levelKey}
|
||||
depth={1}
|
||||
kind="level"
|
||||
title={level.title}
|
||||
label={<strong>{level.title}</strong>}
|
||||
refValue="ROK"
|
||||
stateValue={`${level.series.length} SERII`}
|
||||
hasChildren={level.series.length > 0}
|
||||
expanded={isExpanded(levelKey)}
|
||||
onToggle={() => toggle(levelKey)}
|
||||
/>
|
||||
{isExpanded(levelKey) && level.series.map(series => {
|
||||
const seriesKey = `${levelKey}:series:${series.id}`;
|
||||
return (
|
||||
<React.Fragment key={seriesKey}>
|
||||
<CardLibraryTreeRow
|
||||
id={seriesKey}
|
||||
depth={2}
|
||||
kind="series"
|
||||
title={series.title}
|
||||
label={<strong>{series.title}</strong>}
|
||||
refValue={series.role === "reference" ? "REF." : "SERIA"}
|
||||
stateValue={`${series.cards.length} KART`}
|
||||
hasChildren={series.cards.length > 0}
|
||||
expanded={isExpanded(seriesKey)}
|
||||
onToggle={() => toggle(seriesKey)}
|
||||
/>
|
||||
{isExpanded(seriesKey) && series.cards.map(card => {
|
||||
const cardKey = `${seriesKey}:card:${card.id}`;
|
||||
const tasks = card.tasks ?? [];
|
||||
const body = (
|
||||
const available = card.current || card.available;
|
||||
const state = card.current ? "current" : card.available ? "online" : "plan";
|
||||
return (
|
||||
<React.Fragment key={cardKey}>
|
||||
<CardLibraryTreeRow
|
||||
id={cardKey}
|
||||
depth={3}
|
||||
kind="card"
|
||||
title={`${card.id} · ${card.title}`}
|
||||
label={(
|
||||
<>
|
||||
<span className="card-library-node" aria-hidden="true">
|
||||
{cardIndex === series.cards.length - 1 ? "└─" : "├─"}
|
||||
</span>
|
||||
<span className="card-library-card-copy">
|
||||
<strong>{card.id}</strong>
|
||||
<span>{card.title}</span>
|
||||
</span>
|
||||
<span className="card-library-card-meta">
|
||||
{card.version && <small>{card.version}</small>}
|
||||
<small>{card.current ? "BIEŻĄCA" : card.available ? "ONLINE" : "PLAN"}</small>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li className="card-library-card-entry" key={`${series.id}:${card.id}`}>
|
||||
{card.current || card.available ? (
|
||||
<a
|
||||
className={`card-library-card is-available${card.current ? " is-current" : ""}`}
|
||||
href={card.current ? "#" : card.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={card.current ? "page" : undefined}
|
||||
title={card.current ? "Pokaż bieżącą kartę" : `Otwórz kartę ${card.id}`}
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="card-library-card is-unavailable"
|
||||
aria-disabled="true"
|
||||
title="Karta nie ma jeszcze wygenerowanego adresu"
|
||||
>
|
||||
{body}
|
||||
</span>
|
||||
)}
|
||||
{tasks.length > 0 && (
|
||||
<ul className="card-library-tasks">
|
||||
{tasks.map((task, taskIndex) => {
|
||||
const taskBody = (
|
||||
<>
|
||||
<span className="card-library-node" aria-hidden="true">
|
||||
{taskIndex === tasks.length - 1 ? "└─" : "├─"}
|
||||
</span>
|
||||
<strong>{task.id.toUpperCase()}</strong>
|
||||
<span>{task.title}</span>
|
||||
</>
|
||||
);
|
||||
refValue={card.version || "—"}
|
||||
stateValue={card.current ? "BIEŻĄCA" : card.available ? "ONLINE" : "PLAN"}
|
||||
stateKind={state}
|
||||
hasChildren={tasks.length > 0}
|
||||
expanded={isExpanded(cardKey)}
|
||||
onToggle={() => toggle(cardKey)}
|
||||
href={available ? (card.current ? "#" : card.href) : undefined}
|
||||
current={card.current}
|
||||
unavailable={!available}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
{isExpanded(cardKey) && tasks.map(task => {
|
||||
const taskHref = card.current
|
||||
? `#${task.id}`
|
||||
: `${card.href}#${task.id}`;
|
||||
return (
|
||||
<li key={`${card.id}:${task.id}`}>
|
||||
{card.current || card.available ? (
|
||||
<a href={taskHref} onClick={onNavigate}>{taskBody}</a>
|
||||
) : (
|
||||
<span>{taskBody}</span>
|
||||
<CardLibraryTreeRow
|
||||
key={`${cardKey}:task:${task.id}`}
|
||||
id={`${cardKey}:task:${task.id}`}
|
||||
depth={4}
|
||||
kind="task"
|
||||
title={`${task.id.toUpperCase()} · ${task.title}`}
|
||||
label={(
|
||||
<>
|
||||
<strong>{task.id.toUpperCase()}</strong>
|
||||
<span>{task.title}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
refValue="TASK"
|
||||
stateValue={available ? "GOTOWY" : "PLAN"}
|
||||
stateKind={available ? "online" : "plan"}
|
||||
href={available ? taskHref : undefined}
|
||||
unavailable={!available}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</nav>
|
||||
<footer className="card-library-footer">
|
||||
<span><i className="is-online" /> online</span>
|
||||
<span><i className="is-draft" /> plan / brak strony</span>
|
||||
<span>{library.workspace || "STEM"}</span>
|
||||
<span>{seriesCount} serii</span>
|
||||
<span><i className="is-online" /> {availableCount}/{cardCount} online</span>
|
||||
<kbd>Esc</kbd>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -679,6 +679,178 @@
|
||||
padding: 2px 5px;
|
||||
font: 600 16px/1 ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Shared tree-widget presentation: the library uses the same tabular grammar
|
||||
as the classroom tree (tree column, fixed metadata columns, sticky head). */
|
||||
.card-library-treegrid {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 8px;
|
||||
border: 1px solid rgb(31 35 40 / 18%);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
scrollbar-color: #93a2a8 #edf1f2;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.card-library-treegrid-row {
|
||||
display: grid;
|
||||
min-width: 760px;
|
||||
min-height: 38px;
|
||||
box-sizing: border-box;
|
||||
grid-template-columns: minmax(500px, 1fr) 110px 120px;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid rgb(31 35 40 / 9%);
|
||||
color: #273941;
|
||||
font: 500 16px/1.25 ui-monospace, monospace;
|
||||
}
|
||||
.card-library-treegrid-row > [role="gridcell"],
|
||||
.card-library-treegrid-row > [role="columnheader"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 7px 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-library-treegrid-row > [role="gridcell"] + [role="gridcell"],
|
||||
.card-library-treegrid-row > [role="columnheader"] + [role="columnheader"] {
|
||||
border-left: 1px solid rgb(31 35 40 / 9%);
|
||||
}
|
||||
.card-library-treegrid-head {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
min-height: 34px;
|
||||
border-bottom-color: rgb(31 35 40 / 20%);
|
||||
background: #eef1f2;
|
||||
color: #3e525b;
|
||||
font-weight: 700;
|
||||
letter-spacing: .045em;
|
||||
}
|
||||
.card-library-treegrid-row:not(.card-library-treegrid-head):hover {
|
||||
background: rgb(31 35 40 / 4%);
|
||||
}
|
||||
.card-library-treegrid-row.is-subject {
|
||||
background: #dfe8e5;
|
||||
}
|
||||
.card-library-treegrid-row.is-level {
|
||||
background: #f5f8f8;
|
||||
}
|
||||
.card-library-treegrid-row.is-series {
|
||||
background: #f9fbfb;
|
||||
}
|
||||
.card-library-treegrid-row.is-card {
|
||||
min-height: 42px;
|
||||
background: #fff;
|
||||
}
|
||||
.card-library-treegrid-row.is-task {
|
||||
background: #fbfcfc;
|
||||
}
|
||||
.card-library-treegrid-row.is-current {
|
||||
box-shadow: inset 3px 0 #1680a5;
|
||||
background: #e1f1f6;
|
||||
}
|
||||
.card-library-tree-title {
|
||||
gap: 6px;
|
||||
padding-left: calc(8px + (var(--card-library-depth, 0) * 21px)) !important;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.card-library-tree-expander {
|
||||
display: inline-grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #405961;
|
||||
padding: 0;
|
||||
font: 18px/1 ui-monospace, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-library-tree-expander:hover,
|
||||
.card-library-tree-expander:focus-visible {
|
||||
background: rgb(22 93 107 / 10%);
|
||||
color: #165d6b;
|
||||
outline: 1px solid rgb(22 93 107 / 28%);
|
||||
}
|
||||
.card-library-tree-expander.is-empty {
|
||||
cursor: default;
|
||||
}
|
||||
.card-library-tree-link,
|
||||
.card-library-tree-label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
color: #10181c;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-library-tree-link:hover,
|
||||
.card-library-tree-link:focus-visible {
|
||||
color: #075d7c;
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.card-library-tree-link strong,
|
||||
.card-library-tree-label strong {
|
||||
flex: none;
|
||||
color: inherit;
|
||||
font: 700 16px/1.25 ui-monospace, monospace;
|
||||
}
|
||||
.card-library-tree-link > span,
|
||||
.card-library-tree-label > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 500 16px/1.25 system-ui, sans-serif;
|
||||
}
|
||||
.card-library-treegrid-row.is-subject .card-library-tree-label strong,
|
||||
.card-library-treegrid-row.is-level .card-library-tree-label strong,
|
||||
.card-library-treegrid-row.is-series .card-library-tree-label strong {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-weight: 650;
|
||||
}
|
||||
.card-library-tree-state {
|
||||
display: inline-flex;
|
||||
min-width: 76px;
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: #33474f;
|
||||
padding: 2px 7px;
|
||||
font: 650 14px/1 ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-library-tree-state[data-state="current"] {
|
||||
border-color: #3184a0;
|
||||
background: #e9f5f8;
|
||||
color: #075d7c;
|
||||
}
|
||||
.card-library-tree-state[data-state="online"] {
|
||||
border-color: #4b9a7a;
|
||||
background: #edf8f2;
|
||||
color: #176144;
|
||||
}
|
||||
.card-library-tree-state[data-state="plan"] {
|
||||
border-color: #c59a49;
|
||||
background: #fff7e5;
|
||||
color: #775313;
|
||||
}
|
||||
.card-library-treegrid-row.is-unavailable .card-library-tree-label,
|
||||
.card-library-treegrid-row.is-unavailable .card-library-tree-label strong {
|
||||
color: #10181c;
|
||||
}
|
||||
.side-toc,
|
||||
.side-progress {
|
||||
min-height: 0;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+9
-9
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user