feat: standardize card library as treegrid widget
Check card layouts / check (push) Has been cancelled

This commit is contained in:
2026-07-19 17:41:30 +02:00
parent 8bf8e22448
commit 92d7df2a38
4 changed files with 421 additions and 140 deletions
+224 -115
View File
@@ -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({ function CardLibraryPanel({
library, library,
onNavigate, onNavigate,
@@ -4626,141 +4707,169 @@ function CardLibraryPanel({
), ),
0, 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 ( return (
<div className="card-library-panel"> <div className="card-library-panel">
<header className="card-library-header"> <div
<div> className="card-library-treegrid"
<small>{library.workspace || "STEM"} · KATALOG</small> role="treegrid"
<h2 id="card-library-title">Serie i karty</h2> aria-label="Serie, karty i taski"
<p>{seriesCount} serii · {availableCount}/{cardCount} kart online</p> 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> </div>
</header> {library.subjects.map(subject => {
<nav className="card-library-tree" aria-label="Drzewo serii i kart"> const subjectKey = `subject:${subject.id}`;
{library.subjects.map(subject => ( return (
<details <React.Fragment key={subjectKey}>
className="card-library-subject" <CardLibraryTreeRow
key={subject.id} id={subjectKey}
open={subject.current || undefined} depth={0}
> kind="subject"
<summary> title={subject.title}
<span className="card-library-branch" aria-hidden="true"></span> label={<strong>{subject.title}</strong>}
<span> refValue="PRZEDMIOT"
<strong>{subject.title}</strong> stateValue={`${subject.levels.length} POZ.`}
<small>PRZEDMIOT</small> hasChildren={subject.levels.length > 0}
</span> expanded={isExpanded(subjectKey)}
<output>{subject.levels.length}</output> onToggle={() => toggle(subjectKey)}
</summary> />
<div className="card-library-level-list"> {isExpanded(subjectKey) && subject.levels.map(level => {
{subject.levels.map(level => ( const levelKey = `${subjectKey}:level:${level.id}`;
<details return (
className="card-library-level" <React.Fragment key={levelKey}>
key={level.id} <CardLibraryTreeRow
open={level.current || undefined} id={levelKey}
> depth={1}
<summary> kind="level"
<span className="card-library-branch" aria-hidden="true"></span> title={level.title}
<strong>{level.title}</strong> label={<strong>{level.title}</strong>}
<output>{level.series.length}</output> refValue="ROK"
</summary> stateValue={`${level.series.length} SERII`}
<div className="card-library-series-list"> hasChildren={level.series.length > 0}
{level.series.map(series => ( expanded={isExpanded(levelKey)}
<details onToggle={() => toggle(levelKey)}
className="card-library-series" />
key={`${level.id}:${series.id}`} {isExpanded(levelKey) && level.series.map(series => {
open={series.current || undefined} const seriesKey = `${levelKey}:series:${series.id}`;
> return (
<summary> <React.Fragment key={seriesKey}>
<span className="card-library-branch" aria-hidden="true"></span> <CardLibraryTreeRow
<span> id={seriesKey}
<strong>{series.title}</strong> depth={2}
{series.role === "reference" && <small>REFERENCJA</small>} kind="series"
</span> title={series.title}
<output>{series.cards.length}</output> label={<strong>{series.title}</strong>}
</summary> refValue={series.role === "reference" ? "REF." : "SERIA"}
{series.cards.length > 0 && ( stateValue={`${series.cards.length} KART`}
<ul className="card-library-cards"> hasChildren={series.cards.length > 0}
{series.cards.map((card, cardIndex) => { expanded={isExpanded(seriesKey)}
onToggle={() => toggle(seriesKey)}
/>
{isExpanded(seriesKey) && series.cards.map(card => {
const cardKey = `${seriesKey}:card:${card.id}`;
const tasks = card.tasks ?? []; 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> <strong>{card.id}</strong>
<span>{card.title}</span> <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 && ( refValue={card.version || "—"}
<ul className="card-library-tasks"> stateValue={card.current ? "BIEŻĄCA" : card.available ? "ONLINE" : "PLAN"}
{tasks.map((task, taskIndex) => { stateKind={state}
const taskBody = ( hasChildren={tasks.length > 0}
<> expanded={isExpanded(cardKey)}
<span className="card-library-node" aria-hidden="true"> onToggle={() => toggle(cardKey)}
{taskIndex === tasks.length - 1 ? "└─" : "├─"} href={available ? (card.current ? "#" : card.href) : undefined}
</span> current={card.current}
<strong>{task.id.toUpperCase()}</strong> unavailable={!available}
<span>{task.title}</span> onNavigate={onNavigate}
</> />
); {isExpanded(cardKey) && tasks.map(task => {
const taskHref = card.current const taskHref = card.current
? `#${task.id}` ? `#${task.id}`
: `${card.href}#${task.id}`; : `${card.href}#${task.id}`;
return ( return (
<li key={`${card.id}:${task.id}`}> <CardLibraryTreeRow
{card.current || card.available ? ( key={`${cardKey}:task:${task.id}`}
<a href={taskHref} onClick={onNavigate}>{taskBody}</a> id={`${cardKey}:task:${task.id}`}
) : ( depth={4}
<span>{taskBody}</span> 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> </React.Fragment>
)} );
</li> })}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
); );
})} })}
</ul>
)}
</details>
))}
</div> </div>
</details>
))}
</div>
</details>
))}
</nav>
<footer className="card-library-footer"> <footer className="card-library-footer">
<span><i className="is-online" /> online</span> <span>{library.workspace || "STEM"}</span>
<span><i className="is-draft" /> plan / brak strony</span> <span>{seriesCount} serii</span>
<span><i className="is-online" /> {availableCount}/{cardCount} online</span>
<kbd>Esc</kbd> <kbd>Esc</kbd>
</footer> </footer>
</div> </div>
+172
View File
@@ -679,6 +679,178 @@
padding: 2px 5px; padding: 2px 5px;
font: 600 16px/1 ui-monospace, monospace; 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-toc,
.side-progress { .side-progress {
min-height: 0; min-height: 0;
+1 -1
View File
File diff suppressed because one or more lines are too long
+9 -9
View File
File diff suppressed because one or more lines are too long