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:
2026-09-28 18:38:02 +07:00
parent 19fd5b644f
commit 4c65923eff
6 changed files with 133 additions and 26 deletions
+71 -20
View File
@@ -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')}