diff --git a/frontend/src/components/LibraryColumns.css b/frontend/src/components/LibraryColumns.css new file mode 100644 index 0000000..9f634ca --- /dev/null +++ b/frontend/src/components/LibraryColumns.css @@ -0,0 +1,84 @@ +/* ── The library, as columns ────────────────────────────────────────── + One strip, scrolled sideways, with a column per level. The strip is the + scroller rather than the page, so descending a tree never moves the page + under the reader — only the columns travel. */ +.lib { + display: flex; + align-items: flex-start; + gap: 26px; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 4px 20px; + /* Each column comes to rest at the left edge when the strip is dragged, so + a flick lands on a column rather than between two. */ + scroll-snap-type: x proximity; + scrollbar-width: thin; +} +.lib-col { + flex: 0 0 min(330px, 78vw); + min-width: 0; + scroll-snap-align: start; +} +.lib-col-head { + margin: 0 0 14px; + font-size: 1.35rem; + font-weight: 700; + line-height: 1.25; + color: var(--text); + overflow-wrap: anywhere; +} + +/* The rows of one level sit on a card; the level you are inside is the one + with a card under it, which is what separates "where I am" from "where I + came from" without a second colour. */ +.lib-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.lib-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 13px 14px; + font: inherit; + font-size: 0.92rem; + text-align: left; + color: var(--text); + background: none; + border: 0; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.lib-row:last-child { border-bottom: 0; } +.lib-row:hover { background: var(--bg); } +/* The branch you opened stays lit, so the path you took is readable across + the whole strip rather than only in the column you are looking at. */ +.lib-row.is-open { background: var(--bg); font-weight: 600; } + +.lib-icon { flex: none; font-size: 1rem; line-height: 1; color: var(--primary); } +.lib-icon.is-leaf { color: var(--text-subtle); } +.lib-label { flex: 1; min-width: 0; line-height: 1.4; overflow-wrap: anywhere; } +.lib-chev { flex: none; color: var(--text-subtle); font-size: 1.1rem; line-height: 1; } +.lib-row.is-open .lib-chev { color: var(--primary); } + +.lib-empty { + margin: 0; + padding: 16px 14px; + font-size: 0.86rem; + color: var(--text-muted); +} + +@media (prefers-reduced-motion: reduce) { + .lib { scroll-behavior: auto; } +} + +@media (max-width: 640px) { + /* One column at a time, and the snap becomes mandatory: a half-column on a + phone is a column you cannot read beside one you cannot finish. */ + .lib { gap: 16px; scroll-snap-type: x mandatory; } + .lib-col { flex-basis: 88vw; } + .lib-col-head { font-size: 1.1rem; } +} diff --git a/frontend/src/components/LibraryColumns.jsx b/frontend/src/components/LibraryColumns.jsx new file mode 100644 index 0000000..9c0aa32 --- /dev/null +++ b/frontend/src/components/LibraryColumns.jsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import './LibraryColumns.css' + +/** + * Browsing a tree as a row of columns rather than a tree you expand in place. + * + * An expanding tree grows downwards, so opening a branch six deep pushes + * everything below it off the screen and the thing you are looking at ends up + * somewhere you have to hunt for. Columns grow sideways instead: each level is + * its own list, the row you opened stays lit where you left it, and the path + * you took is readable straight across the headings. + * + * The strip scrolls so that the column you have just opened is at the right + * edge and the one before it is still beside it — two levels of context, and + * the ancestors clipped off to the left with their headings still legible, so + * going back is a click on something visible rather than a Back button. + * + * `nodes` is the flat list — every row with an `id`, a `name` and a + * `parent_id` — because that is what the API hands over, and building the + * columns here means the tree is never assembled twice. + */ +export default function LibraryColumns({ + nodes = [], + leaves = {}, + onOpenLeaf, + emptyLabel = 'Nothing filed here yet.', + rootLabel = 'Library', +}) { + // The chain of opened branches, outermost first. The columns are derived + // from it rather than stored beside it: two records of one thing is how a + // heading ends up naming a column that is no longer there. + const [path, setPath] = useState([]) + const strip = useRef(null) + + const childrenOf = useCallback( + (parentId) => nodes + .filter(node => (node.parent_id ?? null) === parentId) + .sort((a, b) => a.name.localeCompare(b.name)), + [nodes], + ) + + const columns = [{ id: null, name: rootLabel, rows: childrenOf(null) }] + for (const node of path) columns.push({ ...node, rows: childrenOf(node.id) }) + + // Layout, not effect: the scroll must land with the paint, or the new column + // appears at the right edge and then jumps. + useLayoutEffect(() => { + const el = strip.current + if (el) el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }) + }, [path.length]) + + useEffect(() => { + const onKey = (event) => { + if (event.key === 'Escape' && path.length) setPath(p => p.slice(0, -1)) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [path.length]) + + const open = (depth, node) => setPath(p => [...p.slice(0, depth), node]) + + return ( +
{emptyLabel}
+ )} + {/* Branches first, then what is filed at this level. A folder and a + page in one list, shuffled together alphabetically, makes the + reader check an icon on every row to know what a click does. */} + {column.rows.map(row => { + const isOpen = path[depth]?.id === row.id + return ( + + ) + })} + {(leaves[column.id] || []).map(leaf => ( + + ))} +