web: rename a folder, and add one from the empty column
A folder's own menu gains a rename, which paints a label over the folder without moving it — the frame ids and the recipes hang off the directory's name, not the label. The empty part of the column answers a right click with ADD FOLDER, the FOLDERS heading goes (the count already rides on each row), and the column narrows to 118px so the stage takes the room.
This commit is contained in:
@@ -231,10 +231,42 @@ step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-resc
|
||||
await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
||||
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
||||
step('a right click on a folder opens its menu', menu.join(',') === 'lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu));
|
||||
step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu));
|
||||
await page.keyboard.press('Escape');
|
||||
step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null);
|
||||
|
||||
// The column is names and counts, nothing else: the count rides on the row.
|
||||
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',
|
||||
`${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`);
|
||||
|
||||
// A rename paints a label over the folder: the row it belongs to, and the frames
|
||||
// under it, do not move — the node key is still the directory's own name.
|
||||
page.once('dialog', (dialog) => dialog.accept('Roll A'));
|
||||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||||
await page.waitForSelector('[data-key="lib-rename-CheckRoll"]', { timeout: 10_000 });
|
||||
await page.click('[data-key="lib-rename-CheckRoll"]');
|
||||
await page.waitForFunction(() => document.querySelector('[data-key="lib-node-CheckRoll"] .adm-album-name')?.textContent === 'Roll A', null, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
step(
|
||||
'a folder can be renamed without being moved',
|
||||
(await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null,
|
||||
await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-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 } });
|
||||
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
||||
const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
||||
step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank));
|
||||
await page.keyboard.press('Escape');
|
||||
step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
pickFolder,
|
||||
readPhotoFile,
|
||||
removeFolder,
|
||||
renameFolder,
|
||||
scanFolder,
|
||||
type LibraryFolder,
|
||||
type LibraryPhoto,
|
||||
@@ -50,8 +51,10 @@ export function Library() {
|
||||
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The folder menu: what a right click on a tree row opens, and where.
|
||||
const [menu, setMenu] = useState<{ folder: LibraryFolder; x: number; y: number } | null>(null);
|
||||
// The folder menu: what a right click opens, and where. No folder means the
|
||||
// right click landed on the empty part of the column, which is the way in for
|
||||
// one more folder.
|
||||
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; x: number; y: number } | null>(null);
|
||||
const stopRef = useRef(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
@@ -151,6 +154,21 @@ export function Library() {
|
||||
[scan, t]
|
||||
);
|
||||
|
||||
// A rename only paints a label over the folder — the directory it points at is
|
||||
// not touched, which is the whole point of keeping the two apart.
|
||||
const rename = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
// The browser's own box: one line, no dialog to build, and it already knows
|
||||
// how to ask. ponytail: swap it for an inline field if a label ever needs
|
||||
// validating or previewing.
|
||||
const label = window.prompt(t('lib.renameAsk'), folder.label ?? folder.name)?.trim();
|
||||
if (!label || label === (folder.label ?? folder.name)) return;
|
||||
await renameFolder(folder, label);
|
||||
setFolders((prev) => prev.map((f) => (f.name === folder.name ? { ...f, label } : f)));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const drop = useCallback(
|
||||
async (folder: LibraryFolder) => {
|
||||
await removeFolder(folder.name);
|
||||
@@ -199,7 +217,7 @@ export function Library() {
|
||||
}
|
||||
const out: Node[] = [];
|
||||
for (const folder of folders) {
|
||||
out.push({ key: folder.name, label: folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
|
||||
out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
|
||||
// Path order puts a folder before its own children, which is the order the
|
||||
// indented rows have to come in.
|
||||
const own = [...(dirs.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b));
|
||||
@@ -323,13 +341,21 @@ export function Library() {
|
||||
|
||||
{folders.length > 0 ? (
|
||||
<div className="adm-col">
|
||||
<h3 className="adm-row-title">
|
||||
{t('lib.tree')}
|
||||
<span className="adm-row-count">{shown.length}</span>
|
||||
</h3>
|
||||
|
||||
<div className="adm-split">
|
||||
<aside className="adm-album-list" data-key="lib-tree">
|
||||
{/* 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. */}
|
||||
<aside
|
||||
className="adm-album-list"
|
||||
data-key="lib-tree"
|
||||
title={t('lib.colHint')}
|
||||
onContextMenu={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
e.preventDefault();
|
||||
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}
|
||||
@@ -338,12 +364,12 @@ export function Library() {
|
||||
data-key={`lib-node-${n.key}`}
|
||||
aria-current={node === n.key}
|
||||
title={t('lib.nodeHint')}
|
||||
style={{ paddingLeft: 9 + n.depth * 14 }}
|
||||
style={{ paddingLeft: 9 + n.depth * 12 }}
|
||||
onClick={() => setNode(n.key)}
|
||||
// Rescan and remove act on the folder that was picked, so a
|
||||
// right click anywhere on a roll brings up that root's menu.
|
||||
// Shift+F10 raises the same event, which is the keyboard's
|
||||
// way in.
|
||||
// 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;
|
||||
@@ -445,13 +471,38 @@ export function Library() {
|
||||
<p className="adm-note hint">{t('lib.unsupported')}</p>
|
||||
)}
|
||||
|
||||
{/* The folder menu: the two things that belong to a folder rather than to a
|
||||
frame, kept off the page until they are asked for. A folder whose
|
||||
permission has lapsed gets the one that can be done about it. */}
|
||||
{/* The context menu: a folder gets what belongs to a folder rather than to a
|
||||
frame, and the empty part of the column gets the folder that is missing.
|
||||
A folder whose permission has lapsed gets the one thing that can be done
|
||||
about it. */}
|
||||
{menu ? (
|
||||
<div className="lib-menu" data-key="lib-menu" style={{ left: menu.x, top: menu.y }} onContextMenu={(e) => e.preventDefault()}>
|
||||
{ready.has(menu.folder.name) ? (
|
||||
{!menu.folder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key="lib-menu-add"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void add();
|
||||
}}
|
||||
>
|
||||
{t('lib.add')}
|
||||
</button>
|
||||
) : ready.has(menu.folder.name) ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
data-key={`lib-rename-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void rename(menu.folder!);
|
||||
}}
|
||||
>
|
||||
{t('lib.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lib-menu-item"
|
||||
@@ -459,7 +510,7 @@ export function Library() {
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void scan(menu.folder);
|
||||
void scan(menu.folder!);
|
||||
}}
|
||||
>
|
||||
{t('lib.rescan')}
|
||||
@@ -470,7 +521,7 @@ export function Library() {
|
||||
data-key={`lib-drop-${menu.folder.name}`}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void drop(menu.folder);
|
||||
void drop(menu.folder!);
|
||||
}}
|
||||
>
|
||||
{t('lib.remove')}
|
||||
@@ -484,7 +535,7 @@ export function Library() {
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMenu(null);
|
||||
void reconnect(menu.folder);
|
||||
void reconnect(menu.folder!);
|
||||
}}
|
||||
>
|
||||
{t('lib.reconnect')}
|
||||
|
||||
@@ -30,7 +30,11 @@ const THUMB_QUALITY = 0.75;
|
||||
const BATCH = 50;
|
||||
|
||||
export interface LibraryFolder {
|
||||
// The directory's own name: the store's key, and the prefix of every frame id
|
||||
// found in it. A rename never touches this.
|
||||
name: string;
|
||||
// What the tree paints instead, when the visitor has renamed the folder.
|
||||
label?: string;
|
||||
handle: FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
@@ -131,6 +135,13 @@ export async function pickFolder(): Promise<LibraryFolder | null> {
|
||||
return folder;
|
||||
}
|
||||
|
||||
// A rename is a label over the folder, not a new folder: everything downstream —
|
||||
// the frame ids, the thumbnail store, the recipe each frame was left at — hangs
|
||||
// off `name`, which stays the directory's own.
|
||||
export async function renameFolder(folder: LibraryFolder, label: string): Promise<void> {
|
||||
await ask(FOLDERS, 'readwrite', (s) => s.put({ ...folder, label }));
|
||||
}
|
||||
|
||||
export async function removeFolder(name: string): Promise<void> {
|
||||
const ids = (await listPhotos(name)).map((p) => p.id);
|
||||
const db = await openDb();
|
||||
|
||||
@@ -230,11 +230,13 @@ export const en: Dict = {
|
||||
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
|
||||
'lib.add': 'ADD FOLDER',
|
||||
'lib.stop': 'STOP SCAN',
|
||||
'lib.rename': 'RENAME FOLDER',
|
||||
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
|
||||
'lib.rescan': 'RESCAN',
|
||||
'lib.remove': 'REMOVE',
|
||||
'lib.reconnect': 'GRANT ACCESS AGAIN',
|
||||
'lib.tree': 'FOLDERS',
|
||||
'lib.nodeHint': 'Right-click (or Shift+F10) to rescan or remove the folder.',
|
||||
'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.viewPreview': 'SINGLE',
|
||||
'lib.viewGrid': 'ALL THUMBS',
|
||||
'lib.open': 'Open in the studio',
|
||||
|
||||
@@ -243,11 +243,13 @@ export const vi = {
|
||||
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
|
||||
'lib.add': 'THÊM THƯ MỤC',
|
||||
'lib.stop': 'DỪNG QUÉT',
|
||||
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
|
||||
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
|
||||
'lib.rescan': 'QUÉT LẠI',
|
||||
'lib.remove': 'BỎ',
|
||||
'lib.reconnect': 'CẤP LẠI QUYỀN',
|
||||
'lib.tree': 'THƯ MỤC',
|
||||
'lib.nodeHint': 'Chuột phải (hoặc Shift+F10) để quét lại hay bỏ thư mục.',
|
||||
'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.viewPreview': 'MỘT ẢNH',
|
||||
'lib.viewGrid': 'TOÀN BỘ',
|
||||
'lib.open': 'Mở trong studio',
|
||||
|
||||
@@ -1006,7 +1006,16 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
/* The column: title and the folder's own chips stay put, the split takes what
|
||||
is left, the strip keeps its natural height on the bottom. */
|
||||
.lib-root .adm-col { flex: 1 1 auto; min-height: 0; }
|
||||
.lib-root .adm-col > .adm-split { flex: 1 1 auto; min-height: 0; align-items: stretch; }
|
||||
/* 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. */
|
||||
.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;
|
||||
}
|
||||
.lib-root .adm-album { padding: 6px 8px; gap: 6px; }
|
||||
.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