wrolpi/app/src/components/Bookmarks.js
lrnselfreliance 86d4bbab98
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Bookmarks: a nav menu of user links, stored in bookmarks.yaml (#729)
* bookmarks: user bookmarks in the navigation bar, stored in bookmarks.yaml

A "Bookmarks" tab between Calculators and Flasher opens on hover or click and
lists the user's bookmarks, with directories as flyout submenus (Menu.Sub).
The mobile hamburger shows directories as labelled groups instead.  The tab
always shows, with an "Edit bookmarks" item, so the feature can be found
before any bookmark exists.

Bookmarks live in a new config-only bookmarks.yaml: a nested tree where a
node with `children` is a directory and a node with `url` is a bookmark.
Every node carries an integer id so the API can rename, move and delete
without relying on names.  A URL may be a path on this WROLPi, a port on this
host (`:8096/` or `http://:8096/`, resolved against whatever hostname the
browser used, so it works over LAN IP, mDNS and the hotspot alike), or an
absolute URL.  Each bookmark chooses whether to open in a new tab.

The editor at /more/bookmarks follows the playlist items table: numbered rows,
up/down/edit/delete, and inline "Add a bookmark" / "Add a directory" forms
with a Location picker.  Deleting a directory asks first, since its contents
go with it.  The menu and the editor are in the component gallery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: mark More current when the editor is folded inside it; rem indents in the mobile menu

Found on the QA Pi at a width where Bookmarks overflows into More: the More
button carried no mark on /more/bookmarks, because the bookmarks link has no
`to` for isLinkActive to match.  isLinkActive now knows the bookmarks link owns
its editor page, and the Bookmarks tab uses the same rule.

The mobile menu's directory labels sat a few pixels left of the labels around
them: the indent was in em, and a Menu.Label is set smaller than a Menu.Item.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bookmarks: parse URLs instead of matching a prefix; validate the whole tree on every write

The URL contract was checked by prefix on both sides, and a prefix is not a
form: `//evil.com` starts like a path and `:8096@evil.com` starts like a port,
and a browser takes both off this host.  A path is now a single leading slash
that resolves to this origin, a port form is an optional scheme, `:`, digits
and then only a path, query or fragment (no `@`, no backslash, no control
characters), and anything else must parse as http(s).  The same rules live in
wrolpi/bookmarks.py and BookmarksContext.js, with the same bypass strings in
both test suites.

The config's dataclass validator only saw that `bookmarks` was a list, so a
hand-edited file or the generic config API could store a `javascript:` URL
that the bookmarks API would refuse, and the nav rendered it.  The config now
validates the tree itself on every write and import: URL forms, exactly one of
url/children, unique integer ids, boolean new_tab, and no cycles (a YAML
anchor can build one).  An invalid file is not imported and, since it was never
successfully imported, is never overwritten.

On the page, a stored URL that does not resolve is rendered as text, never as
an href.  A refused add keeps what was typed instead of clearing the form, and
an edit whose move is refused still refreshes the nav (the rename was
committed) and keeps the modal open.  An IPv6 host is bracketed before a port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* help: document Bookmarks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:11:36 -06:00

453 lines
18 KiB
JavaScript

import React, {useState} from 'react';
import {Link} from 'react-router';
import {IconBookmark} from '@tabler/icons-react';
import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
import {APIButton, PageContainer, useTitle} from './Common';
import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
/**
* The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
* anchor for everything else, and a new tab when the bookmark asks for one. A URL that
* does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
*/
function bookmarkItemProps(node) {
const {internal, href} = resolveBookmarkUrl(node.url);
if (!href) {
return {disabled: true};
}
const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
if (internal) {
return {component: Link, to: href, ...target};
}
return {component: 'a', href, ...target};
}
/**
* The items of a bookmarks menu.
*
* `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
* Without it a directory is a labelled, indented run of items instead: the mobile
* hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
* indent is in rem: a label is set smaller than an item, so an em indent would put a
* directory's label a few pixels left of the items beside it.
*/
export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
if (!nodes || nodes.length === 0) {
return null;
}
return nodes.map(node => {
if (isBookmarkDirectory(node)) {
if (nested) {
return <Menu.Sub key={node.id}>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<Icon name='folder' size='small'/>}>{node.name}</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{node.children.length > 0
? <BookmarkMenuItems nodes={node.children} nested={nested}/>
: <Menu.Item disabled>Empty</Menu.Item>}
</Menu.Sub.Dropdown>
</Menu.Sub>;
}
return <React.Fragment key={node.id}>
<Menu.Label style={{paddingLeft: `${0.75 + depth}rem`}}>{node.name}</Menu.Label>
<BookmarkMenuItems nodes={node.children} nested={false} depth={depth + 1}/>
</React.Fragment>;
}
return <Menu.Item
key={node.id}
{...bookmarkItemProps(node)}
style={nested ? undefined : {paddingLeft: `${0.75 + depth}rem`}}
rightSection={node.new_tab ? <Icon name='external' size='small'/> : undefined}
>
{node.name}
</Menu.Item>;
});
}
/**
* Everything inside a bookmarks dropdown: the bookmarks, then the link to edit them. The
* edit link is always there, so a user with no bookmarks yet can find where to add one.
*/
export function BookmarksMenuContents({nested = true}) {
const {bookmarks} = useBookmarks();
const nodes = bookmarks || [];
return <>
<BookmarkMenuItems nodes={nodes} nested={nested}/>
{nodes.length > 0 && <Menu.Divider/>}
<Menu.Item component={Link} to={EDIT_BOOKMARKS_PATH} leftSection={<Icon name='edit' size='small'/>}>
Edit bookmarks
</Menu.Item>
</>;
}
/** Every directory in the tree with its full path, for the "Location" picker. */
export function directoryOptions(nodes, prefix = '') {
const options = [];
for (const node of nodes || []) {
if (isBookmarkDirectory(node)) {
const label = `${prefix}${node.name}`;
options.push({value: String(node.id), label, id: node.id});
options.push(...directoryOptions(node.children, `${label} / `));
}
}
return options;
}
/** The ids of `node` and everything beneath it. */
const subtreeIds = (node) => {
const ids = [node.id];
if (isBookmarkDirectory(node)) {
node.children.forEach(child => ids.push(...subtreeIds(child)));
}
return ids;
};
const ROOT = 'root';
/** The places a node may live: the top level, and every directory not inside `node`. */
export function locationOptions(nodes, node = null) {
// A directory cannot be moved into itself or its own children.
const excluded = node ? new Set(subtreeIds(node)) : new Set();
return [
{value: ROOT, label: 'Top level'},
...directoryOptions(nodes).filter(i => !excluded.has(i.id)).map(({value, label}) => ({value, label})),
];
}
/**
* The form for one node. `node` is null when adding; `directory` says which kind.
* `parentId` is where a new node goes, or where an edited one currently lives.
*/
export function BookmarkNodeModal({open, node, directory, parentId, nodes, onSave, onClose}) {
const [name, setName] = useState(node?.name || '');
const [url, setUrl] = useState(node?.url || '');
const [newTab, setNewTab] = useState(Boolean(node?.new_tab));
const [location, setLocation] = useState(parentId === null || parentId === undefined ? ROOT : String(parentId));
const [saving, setSaving] = useState(false);
const locations = locationOptions(nodes, node);
const valid = name.trim() && (directory || url.trim());
const title = node ? (directory ? 'Edit directory' : 'Edit bookmark') : (directory ? 'New directory' : 'New bookmark');
const handleSave = async () => {
if (!valid) return;
setSaving(true);
try {
await onSave({
name: name.trim(),
url: url.trim(),
new_tab: newTab,
parent_id: location === ROOT ? null : Number(location),
});
} finally {
setSaving(false);
}
};
return <Modal open={open} onClose={onClose} size='tiny'>
<Modal.Header>{title}</Modal.Header>
<Modal.Content>
<form onSubmit={e => {
e.preventDefault();
handleSave();
}}>
<TextInput
autoFocus
label='Name'
value={name}
onChange={e => setName(e.currentTarget.value)}
/>
{!directory && <TextInput
label='URL'
style={{marginTop: '1em'}}
value={url}
onChange={e => setUrl(e.currentTarget.value)}
placeholder='/videos, :8096/, or https://example.com'
description='A path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/), or a full URL.'
/>}
<Select
label='Location'
style={{marginTop: '1em'}}
data={locations}
value={location}
onChange={value => setLocation(value || ROOT)}
allowDeselect={false}
/>
{!directory && <Toggle
style={{marginTop: '1em'}}
label='Open in a new tab'
checked={newTab}
onChange={e => setNewTab(e.currentTarget.checked)}
/>}
</form>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button role='save' disabled={!valid || saving} onClick={handleSave}>Save</Button>
</Modal.Actions>
</Modal>;
}
/** The rows of the editor, one per node, children beneath their directory and indented. */
function BookmarkTableRows({nodes, parentId, depth, editable, actions}) {
return nodes.map((node, index) => {
const directory = isBookmarkDirectory(node);
const {href} = directory ? {} : resolveBookmarkUrl(node.url);
return <React.Fragment key={node.id}>
<Table.Row>
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
{directory
? <Icon name='folder' size='large'/>
: <Icon name={node.new_tab ? 'external' : 'linkify'} size='large'/>}
</Table.Cell>
<Table.Cell style={{paddingLeft: depth ? `calc(0.75em + ${depth * 1.5}em)` : undefined}}>
{directory && <b>{node.name}</b>}
{!directory && (href
? <a href={href} target={node.new_tab ? '_blank' : undefined} rel='noopener noreferrer'>
{node.name}
</a>
: <span>{node.name}</span>)}
{!directory && <Text size='xs' c='dimmed' truncate='end'>{node.url}</Text>}
</Table.Cell>
{editable && <Table.Cell>
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
<Button icon='arrow up' size='xs' aria-label={`Move ${node.name} up`} disabled={index === 0}
onClick={() => actions.move(node, parentId, index - 1)}/>
<Button icon='arrow down' size='xs' aria-label={`Move ${node.name} down`}
disabled={index === nodes.length - 1}
onClick={() => actions.move(node, parentId, index + 1)}/>
<Button icon='edit' size='xs' aria-label={`Edit ${node.name}`}
onClick={() => actions.edit(node, parentId)}/>
{directory
? <APIButton
role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
confirmContent={`Delete the directory "${node.name}" and everything in it?`}
confirmButton='Delete'
onClick={() => actions.remove(node)}/>
: <Button role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
onClick={() => actions.remove(node)}/>}
</div>
</Table.Cell>}
</Table.Row>
{directory && <BookmarkTableRows nodes={node.children} parentId={node.id} depth={depth + 1}
editable={editable} actions={actions}/>}
</React.Fragment>;
});
}
/**
* The bookmarks table, in the shape of the playlist items table. `actions` carries
* edit/move/remove so the gallery can render it with none of them wired.
*/
export function BookmarkTable({nodes, editable = true, actions}) {
return <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>#</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell>Bookmark</Table.HeaderCell>
{editable && <Table.HeaderCell/>}
</Table.Row>
</Table.Header>
<Table.Body>
<BookmarkTableRows nodes={nodes} parentId={null} depth={0} editable={editable} actions={actions}/>
</Table.Body>
</Table>;
}
/** The "Add a bookmark" and "Add a directory" forms beneath the table. */
export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) {
const [url, setUrl] = useState('');
const [name, setName] = useState('');
const [newTab, setNewTab] = useState(false);
const [location, setLocation] = useState(ROOT);
const [directoryName, setDirectoryName] = useState('');
const [directoryLocation, setDirectoryLocation] = useState(ROOT);
const locations = locationOptions(nodes);
const parentOf = (value) => value === ROOT ? null : Number(value);
// The form is cleared only once the server has accepted the node. A refused add has
// already shown its toast; the typed URL stays so the user can correct it.
const handleAddBookmark = async () => {
if (!url.trim() || !name.trim()) return;
try {
await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
} catch (e) {
return;
}
setUrl('');
setName('');
};
const handleAddDirectory = async () => {
if (!directoryName.trim()) return;
try {
await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
} catch (e) {
return;
}
setDirectoryName('');
};
return <>
<Header as='h4'>Add a bookmark</Header>
<form onSubmit={e => {
e.preventDefault();
handleAddBookmark();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
aria-label='Bookmark URL'
placeholder='URL (e.g. /videos, :8096/, or https://example.com)'
value={url}
onChange={e => setUrl(e.currentTarget.value)}/>
<TextInput
style={{flex: '1 1 200px'}}
aria-label='Bookmark name'
placeholder='Name'
value={name}
onChange={e => setName(e.currentTarget.value)}/>
<Select
style={{flex: '1 1 160px'}}
aria-label='Bookmark location'
data={locations}
value={location}
onChange={value => setLocation(value || ROOT)}
allowDeselect={false}/>
<Toggle
label='New tab'
checked={newTab}
onChange={e => setNewTab(e.currentTarget.checked)}/>
<Button role='primary' type='submit' disabled={!url.trim() || !name.trim()}>Add</Button>
</Group>
</form>
<p style={{marginTop: '0.5em'}}>
<Text size='xs' c='dimmed' component='span'>
A URL is a path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/) that
works whichever address you reach it by, or a full URL.
</Text>
</p>
<Header as='h4'>Add a directory</Header>
<form onSubmit={e => {
e.preventDefault();
handleAddDirectory();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
aria-label='Directory name'
placeholder='Directory name'
value={directoryName}
onChange={e => setDirectoryName(e.currentTarget.value)}/>
<Select
style={{flex: '1 1 160px'}}
aria-label='Directory location'
data={locations}
value={directoryLocation}
onChange={value => setDirectoryLocation(value || ROOT)}
allowDeselect={false}/>
<Button role='primary' type='submit' disabled={!directoryName.trim()}>Add</Button>
</Group>
</form>
</>;
}
export function BookmarksPage() {
useTitle('Bookmarks');
const {bookmarks, refresh} = useBookmarks();
// {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh.
const [modal, setModal] = useState(null);
const [modalKey, setModalKey] = useState(0);
const nodes = bookmarks || [];
const actions = {
edit: (node, parentId) => {
setModalKey(k => k + 1);
setModal({node, directory: isBookmarkDirectory(node), parentId});
},
move: async (node, parentId, position) => {
try {
await moveBookmark(node.id, parentId, position);
await refresh();
} catch (e) {
// Error toast already shown by the API client.
}
},
remove: async (node) => {
try {
await deleteBookmark(node.id);
await refresh();
} catch (e) {
}
},
};
// These reject on failure (the API client has already shown the toast) so the add
// forms know to keep what was typed.
const handleAddBookmark = async (bookmark) => {
await addBookmark(bookmark);
await refresh();
};
const handleAddDirectory = async (directory) => {
await addBookmarkDirectory(directory);
await refresh();
};
const handleEditSave = async ({name, url, new_tab, parent_id}) => {
const {node, directory, parentId} = modal;
try {
await updateBookmark(node.id, directory ? {name} : {name, url, new_tab});
} catch (e) {
return; // Nothing changed; the modal stays open with the toast beside it.
}
// The rename is committed even if the move is refused, so the nav must show it
// either way; only a fully successful edit closes the modal.
let moved = true;
if (parent_id !== parentId) {
try {
await moveBookmark(node.id, parent_id);
} catch (e) {
moved = false;
}
}
await refresh();
if (moved) {
setModal(null);
}
};
return <PageContainer>
<Panel>
<Header as='h1' icon={IconBookmark}>Bookmarks</Header>
<p>
Bookmarks appear in the Bookmarks menu of the navigation bar. They can point at pages on this
WROLPi, at other services running beside it, or anywhere on the web.
</p>
{nodes.length === 0
? <Message title='There are no bookmarks yet'>Add a bookmark below.</Message>
: <BookmarkTable nodes={nodes} actions={actions}/>}
<BookmarkAddForms nodes={nodes} onAddBookmark={handleAddBookmark} onAddDirectory={handleAddDirectory}/>
</Panel>
{modal && <BookmarkNodeModal
key={modalKey}
open
node={modal.node}
directory={modal.directory}
parentId={modal.parentId}
nodes={nodes}
onSave={handleEditSave}
onClose={() => setModal(null)}
/>}
</PageContainer>;
}