web: scan the whole roll, title and size the column, reopen where you left
The tree column now walks every folder under the one you picked instead of its first level, so a roll of dated folders inside dated folders is walked to the bottom. A folder being read turns a ring on its own row, the column is titled with the folder the tree belongs to, and a divider beside it drags the width, which is remembered along with the folder the screen was left on.
This commit is contained in:
@@ -51,6 +51,9 @@ const sampleServer = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
const bytes = await readFile(path.join(SAMPLES, name));
|
||||
// A beat per frame: the catalogue reads two files faster than a poll, and the
|
||||
// ring that says "this roll is being read" needs a window to be caught in.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': bytes.length,
|
||||
@@ -104,23 +107,24 @@ await context.addInitScript(
|
||||
return handle;
|
||||
};
|
||||
// The picked folder holds `names` at its top level and the subfolders in
|
||||
// `subs` — a roll really is a folder of dated directories, which is what the
|
||||
// tree column walks.
|
||||
const dirHandle = (label, files, dirs) => {
|
||||
// `subs`, each `{ files, dirs }` — a roll really is a folder of dated
|
||||
// directories inside dated directories, which is what the tree column walks.
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () =>
|
||||
[...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][
|
||||
Symbol.iterator
|
||||
](),
|
||||
[
|
||||
...(node.files ?? []).map(fileHandle),
|
||||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||||
][Symbol.iterator](),
|
||||
},
|
||||
queryPermission: { value: async () => 'granted' },
|
||||
requestPermission: { value: async () => 'granted' },
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle(folder, names, subs);
|
||||
const root = () => dirHandle(folder, { files: names, dirs: subs });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
|
||||
const revive = (row) => {
|
||||
@@ -145,7 +149,14 @@ await context.addInitScript(
|
||||
};
|
||||
}
|
||||
},
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' }
|
||||
{
|
||||
origin: `http://127.0.0.1:${SAMPLE_PORT}`,
|
||||
names: [JPG],
|
||||
// One frame at the top, one a folder down, one two folders down: the walk has
|
||||
// to reach the bottom of the roll, not just its first level.
|
||||
subs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } } },
|
||||
folder: 'CheckRoll',
|
||||
}
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
@@ -178,10 +189,23 @@ const catalogue = () =>
|
||||
// --- the run ----------------------------------------------------------------
|
||||
await page.goto(`${base}/library`);
|
||||
await page.click('[data-key="lib-add"]');
|
||||
// A folder that is being read says so on its own row, not only in the toolbar.
|
||||
const ringed = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const row = document.querySelector('.adm-album[aria-busy="true"]');
|
||||
return row?.querySelector('.lib-spin') ? row.dataset.key : null;
|
||||
},
|
||||
null,
|
||||
{ timeout: 10_000, polling: 50 }
|
||||
)
|
||||
.then((handle) => handle.jsonValue())
|
||||
.catch(() => null);
|
||||
step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed));
|
||||
const thumbs = '[data-key^="lib-thumb-"]';
|
||||
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 });
|
||||
step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${RAW}`);
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 });
|
||||
step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${RAW}`);
|
||||
|
||||
const indexed = await catalogue();
|
||||
for (const name of [JPG, RAW]) {
|
||||
@@ -195,22 +219,27 @@ const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||||
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
|
||||
);
|
||||
step(
|
||||
'tree lists the folder and its subfolder',
|
||||
nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026',
|
||||
'the tree draws the roll as folders under folders',
|
||||
JSON.stringify(nodes) ===
|
||||
JSON.stringify([
|
||||
['lib-node-CheckRoll', '3'],
|
||||
['lib-node-CheckRoll/2026', '2'],
|
||||
['lib-node-CheckRoll/2026/04', '1'],
|
||||
]),
|
||||
JSON.stringify(nodes)
|
||||
);
|
||||
|
||||
// Opening the subfolder redraws the strip and the frame above it.
|
||||
await page.click('[data-key="lib-node-CheckRoll/2026"]');
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 30_000 });
|
||||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 });
|
||||
const staged = await page.locator('[data-key="lib-stage-meta"]').innerText();
|
||||
step('the open subfolder draws its own strip and frame', /P1010256\.RW2/.test(staged), staged.replace(/\n/g, ' · '));
|
||||
step('the open subfolder draws its own strip and frame', /P1010256\.(RW2|JPG)/.test(staged), staged.replace(/\n/g, ' · '));
|
||||
|
||||
// The switch: the middle becomes every thumbnail of the open node at once.
|
||||
await page.click('[data-key="lib-view-grid"]');
|
||||
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
|
||||
const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
|
||||
step('the grid view draws a card per frame', cards.length === 1, JSON.stringify(cards));
|
||||
step('the grid view draws a card per frame', cards.length === 2, JSON.stringify(cards));
|
||||
await page.click('[data-key="lib-view-preview"]');
|
||||
await page.waitForSelector(thumbs, { timeout: 30_000 });
|
||||
|
||||
@@ -239,7 +268,7 @@ step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null)
|
||||
const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
||||
step(
|
||||
'no FOLDERS heading, the count on the folder row',
|
||||
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '2',
|
||||
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '3',
|
||||
`${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px`
|
||||
);
|
||||
step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`);
|
||||
@@ -258,6 +287,11 @@ step(
|
||||
(await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null,
|
||||
await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)
|
||||
);
|
||||
step(
|
||||
'the column is titled with the folder the tree belongs to',
|
||||
(await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A',
|
||||
await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)
|
||||
);
|
||||
|
||||
// The empty part of the column is where one more folder comes from.
|
||||
await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } });
|
||||
@@ -267,6 +301,23 @@ step('a right click on the empty column offers a folder', blank.join(',') === 'l
|
||||
await page.keyboard.press('Escape');
|
||||
step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null);
|
||||
|
||||
// The divider drags the column, and what the visit leaves behind — the width the
|
||||
// column was left at and the folder that was open — is what the next visit opens
|
||||
// with. The right click on the empty column answered with a folder, not with a
|
||||
// folder opened, so the open node is still the subfolder chosen above.
|
||||
const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox();
|
||||
await page.mouse.move(gripBox.x + 4, gripBox.y + 120);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
||||
await page.reload();
|
||||
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
||||
const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
||||
const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key);
|
||||
step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`);
|
||||
step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where);
|
||||
|
||||
// One tap on a thumbnail raises the frame, and the button under it hands it to
|
||||
// the studio. The RAW is the harder half — it has to be read back off the
|
||||
// (stand-in) handle and developed — so it is the one that gets opened.
|
||||
|
||||
+143
-35
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import {
|
||||
canBrowseFolders,
|
||||
@@ -37,6 +37,32 @@ interface Node {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// The two things this screen remembers between visits: the folder that was open,
|
||||
// and how much room its column was given.
|
||||
const NODE_KEY = 'recipescam.library.node';
|
||||
const COLUMN_KEY = 'recipescam.library.column';
|
||||
const COLUMN_MIN = 88;
|
||||
const COLUMN_MAX = 460;
|
||||
const COLUMN_DEFAULT = 118;
|
||||
|
||||
// Private mode throws on localStorage rather than returning null: a screen that
|
||||
// cannot remember is fine, a screen that cannot load is not.
|
||||
function remembered(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keep(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// The choice just does not outlive the visit.
|
||||
}
|
||||
}
|
||||
|
||||
export function Library() {
|
||||
const { t } = useI18n();
|
||||
const [folders, setFolders] = useState<LibraryFolder[]>([]);
|
||||
@@ -44,10 +70,15 @@ export function Library() {
|
||||
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
|
||||
const [edited, setEdited] = useState<Set<string>>(new Set());
|
||||
// Which tree node is open, which frame is up, and which of the two middle
|
||||
// views (the frame, or every thumbnail of the open node) is on.
|
||||
const [node, setNode] = useState<string | null>(null);
|
||||
// views (the frame, or every thumbnail of the open node) is on. The open node
|
||||
// is picked up where the last visit left it.
|
||||
const [node, setNode] = useState<string | null>(() => remembered(NODE_KEY));
|
||||
const [up, setUp] = useState<string | null>(null);
|
||||
const [view, setView] = useState<'preview' | 'grid'>('preview');
|
||||
const [colWidth, setColWidth] = useState(() => {
|
||||
const saved = Number(remembered(COLUMN_KEY));
|
||||
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
|
||||
});
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -56,6 +87,13 @@ export function Library() {
|
||||
// one more folder.
|
||||
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null);
|
||||
const stopRef = useRef(false);
|
||||
// Where the drag of the column divider started, and how wide the column was
|
||||
// then — the pointer only ever reports a position, not a distance moved.
|
||||
const grip = useRef<{ x: number; w: number } | null>(null);
|
||||
|
||||
// The catalogue is loaded once at start-up; until it is, the tree only knows
|
||||
// the folders and cannot tell whether the remembered subfolder still exists.
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]);
|
||||
@@ -72,7 +110,9 @@ export function Library() {
|
||||
const rows = await listFolders();
|
||||
if (!alive) return;
|
||||
setFolders(rows);
|
||||
setNode((prev) => prev ?? rows[0]?.name ?? null);
|
||||
// The remembered folder only stands while the folder it belongs to does: a
|
||||
// folder that was removed is not a folder to open on.
|
||||
setNode((prev) => (prev && rows.some((f) => f.name === prev.split('/')[0]) ? prev : (rows[0]?.name ?? null)));
|
||||
const ok = new Set<string>();
|
||||
for (const folder of rows) {
|
||||
if (await ensurePermission(folder.handle)) ok.add(folder.name);
|
||||
@@ -80,12 +120,23 @@ export function Library() {
|
||||
if (!alive) return;
|
||||
setReady(ok);
|
||||
await reload();
|
||||
if (!alive) return;
|
||||
setLoaded(true);
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// What the screen remembers: the folder that was open, and the width its column
|
||||
// was dragged to.
|
||||
useEffect(() => {
|
||||
if (node) keep(NODE_KEY, node);
|
||||
}, [node]);
|
||||
useEffect(() => {
|
||||
keep(COLUMN_KEY, String(colWidth));
|
||||
}, [colWidth]);
|
||||
|
||||
// The menu closes on the next click anywhere but its own items, and on Escape.
|
||||
// Escape is the whole reason this listens on the document instead of sitting
|
||||
// behind a full-screen scrim: a scrim would eat the click that opens the next
|
||||
@@ -234,6 +285,17 @@ export function Library() {
|
||||
return out;
|
||||
}, [folders, photos]);
|
||||
|
||||
// The folder the open node belongs to: its name titles the column, and its
|
||||
// handle is what the menu acts on.
|
||||
const root = folders.find((f) => f.name === rootName) ?? null;
|
||||
|
||||
// A remembered subfolder that no longer holds a frame falls back to the folder
|
||||
// it sits in, so the screen never opens on a node that is not drawn.
|
||||
useEffect(() => {
|
||||
if (!loaded || !node || nodes.some((n) => n.key === node)) return;
|
||||
setNode(nodes.find((n) => n.key === node.split('/')[0])?.key ?? nodes[0]?.key ?? null);
|
||||
}, [loaded, node, nodes]);
|
||||
|
||||
// What the open node draws: its own frames, and the ones in every subfolder
|
||||
// under it — a roll's top level usually holds no file of its own.
|
||||
const shown = useMemo(() => {
|
||||
@@ -342,7 +404,7 @@ export function Library() {
|
||||
{folders.length > 0 ? (
|
||||
<div className="adm-col">
|
||||
|
||||
<div className="adm-split">
|
||||
<div className="adm-split" style={{ '--lib-col': `${colWidth}px` } as CSSProperties}>
|
||||
{/* The shelf itself answers a right click on its empty part: the
|
||||
column is the folder control, so one more folder is added
|
||||
where folders are. */}
|
||||
@@ -356,38 +418,84 @@ export function Library() {
|
||||
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) });
|
||||
}}
|
||||
>
|
||||
{nodes.map((n) => (
|
||||
<button
|
||||
key={n.key}
|
||||
type="button"
|
||||
className={`adm-album${node === n.key ? ' on' : ''}`}
|
||||
data-key={`lib-node-${n.key}`}
|
||||
aria-current={node === n.key}
|
||||
title={t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 12 }}
|
||||
onClick={() => setNode(n.key)}
|
||||
// A folder's own menu: rename, scan again, forget. All three
|
||||
// act on the folder that was picked, so a right click on a
|
||||
// subfolder row brings up its roll's menu. Shift+F10 raises
|
||||
// the same event, which is the keyboard's way in.
|
||||
onContextMenu={(e) => {
|
||||
const folder = folders.find((f) => f.name === n.key.split('/')[0]);
|
||||
if (!folder) return;
|
||||
e.preventDefault();
|
||||
const box = e.currentTarget.getBoundingClientRect();
|
||||
setMenu({
|
||||
folder,
|
||||
x: Math.min(e.clientX || box.left, window.innerWidth - 190),
|
||||
y: Math.min(e.clientY || box.top, window.innerHeight - 96),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="adm-album-name">{n.label}</span>
|
||||
<span className="adm-album-count">{n.count}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* The column is titled with the folder the tree belongs to, so
|
||||
a renamed roll is named where its frames are named. */}
|
||||
<b className="lib-col-title" data-key="lib-root-name" title={root?.label || root?.name}>
|
||||
{root?.label || root?.name}
|
||||
</b>
|
||||
{nodes.map((n) => {
|
||||
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
|
||||
// A roll being read says so on its own row: while a scan runs
|
||||
// the toolbar's progress line is nowhere near the eye.
|
||||
const scanning = !!folder && progress?.folder === folder.name;
|
||||
return (
|
||||
<button
|
||||
key={n.key}
|
||||
type="button"
|
||||
className={`adm-album${node === n.key ? ' on' : ''}`}
|
||||
data-key={`lib-node-${n.key}`}
|
||||
aria-current={node === n.key}
|
||||
aria-busy={scanning}
|
||||
title={t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 12 }}
|
||||
onClick={() => setNode(n.key)}
|
||||
// A folder's own menu: rename, scan again, forget. All three
|
||||
// act on the folder that was picked, so a right click on a
|
||||
// subfolder row brings up its roll's menu. Shift+F10 raises
|
||||
// the same event, which is the keyboard's way in.
|
||||
onContextMenu={(e) => {
|
||||
if (!folder) return;
|
||||
e.preventDefault();
|
||||
const box = e.currentTarget.getBoundingClientRect();
|
||||
setMenu({
|
||||
folder,
|
||||
x: Math.min(e.clientX || box.left, window.innerWidth - 190),
|
||||
y: Math.min(e.clientY || box.top, window.innerHeight - 96),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="adm-album-name">{n.label}</span>
|
||||
{scanning ? <span className="lib-spin" aria-hidden="true" /> : null}
|
||||
<span className="adm-album-count">{n.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="lib-grip"
|
||||
data-key="lib-grip"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('lib.colWidth')}
|
||||
aria-valuenow={colWidth}
|
||||
aria-valuemin={COLUMN_MIN}
|
||||
aria-valuemax={COLUMN_MAX}
|
||||
tabIndex={0}
|
||||
title={t('lib.colWidth')}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
grip.current = { x: e.clientX, w: colWidth };
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const held = grip.current;
|
||||
if (!held) return;
|
||||
// 88px is the narrowest a name and its count still read at,
|
||||
// 460 the widest the stage can lose and stay a stage.
|
||||
setColWidth(Math.round(Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, held.w + e.clientX - held.x))));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
grip.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const step = e.key === 'ArrowLeft' ? -12 : e.key === 'ArrowRight' ? 12 : 0;
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
setColWidth((w) => Math.min(COLUMN_MAX, Math.max(COLUMN_MIN, w + step)));
|
||||
}}
|
||||
/>
|
||||
|
||||
<section className="adm-stage" data-key="lib-stage">
|
||||
{view === 'grid' ? (
|
||||
<div className="adm-grid">
|
||||
|
||||
@@ -237,6 +237,7 @@ export const en: Dict = {
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.nodeHint': 'Right-click to rename, rescan or remove the folder.',
|
||||
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
|
||||
'lib.colWidth': 'Drag to set the width of the folder column (arrow keys when focused)',
|
||||
'lib.viewPreview': 'SINGLE',
|
||||
'lib.viewGrid': 'ALL THUMBS',
|
||||
'lib.open': 'Open in the studio',
|
||||
|
||||
@@ -250,6 +250,7 @@ export const vi = {
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.nodeHint': 'Chuột phải để đổi tên, quét lại hay bỏ thư mục.',
|
||||
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',
|
||||
'lib.colWidth': 'Kéo để chỉnh độ rộng cột thư mục (mũi tên trái/phải khi đang chọn)',
|
||||
'lib.viewPreview': 'MỘT ẢNH',
|
||||
'lib.viewGrid': 'TOÀN BỘ',
|
||||
'lib.open': 'Mở trong studio',
|
||||
|
||||
@@ -1007,15 +1007,47 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
is left, the strip keeps its natural height on the bottom. */
|
||||
.lib-root .adm-col { flex: 1 1 auto; min-height: 0; }
|
||||
/* The column of folders is a list of names and a count — the stage is the point
|
||||
of the screen, so the column takes the least it can and gives the rest away. */
|
||||
of the screen, so the column takes the least it can and gives the rest away.
|
||||
Its width is a variable the divider writes, and the divider itself is the middle
|
||||
track, thin enough to read as the seam between the two panels. */
|
||||
.lib-root .adm-col > .adm-split {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(88px, 118px) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: var(--lib-col, 118px) 9px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
.lib-grip { cursor: col-resize; border-radius: 999px; touch-action: none; }
|
||||
.lib-grip:hover, .lib-grip:focus-visible { background: var(--accent-soft); outline: none; }
|
||||
/* The folder the tree belongs to, named over it: a roll's own name is the one
|
||||
thing a renamed folder has to say here. */
|
||||
.lib-col-title {
|
||||
margin: 0 0 6px;
|
||||
padding: 0 2px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lib-root .adm-album { padding: 6px 8px; gap: 6px; }
|
||||
/* A roll being read turns a ring on its own row. */
|
||||
.lib-spin {
|
||||
flex: 0 0 auto;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
animation: lib-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes lib-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lib-spin { animation-duration: 2.4s; }
|
||||
}
|
||||
.lib-root .adm-col > .adm-filmstrip { flex: 0 0 auto; }
|
||||
.lib-root .adm-album-list { max-height: none; flex: 1 1 auto; min-height: 0; }
|
||||
.lib-root .adm-stage { min-height: 0; overflow: hidden; }
|
||||
|
||||
Reference in New Issue
Block a user