wrolpi/app/src/components/AddToPlaylist.js
Roland 6ea9d3645f
ui: Common.js and thirteen more files off Semantic
Phase 2, part 3.  Baseline 63 -> 50.

Common.js is the one that mattered: 51 files import it, so its Semantic usage
was propping up the rest of the migration.  Everything in it now comes from the
library -- Paginator, the two search inputs, DirectorySearch, TabLinks, the
toggles, the messages, APIButton, Breadcrumbs, SimpleAccordion, IframeViewer,
CardPoster, FileIcon, LoadStatistic, the theme menu, and the hotspot icons.

Also migrated: Placeholder, Apps, ArchivePreview, CbzViewer, AddToPlaylist,
TaggedDeleteConfirmModal, the collection edit/tag forms, and four inventory
components.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>` with its grouped results shape
                kept, because four call sites already build it.  A real
                combobox rather than a div that listens for clicks.
  Pagination    Semantic's prop names; onPageChange gets the page number.
  TabBar        the strip only -- routing stays with the caller.
  IconStack     Semantic's IconGroup, named once for assistive technology.
  Table.HeaderCell sorted/onSort -- a real button carrying aria-sort.
  Button/IconButton translate Semantic's size names.

That last one is defensive: unmigrated callers still pass size='tiny', and
Mantine drops a size it does not recognise without a word, so the button
quietly renders at the wrong size.  Mapping here keeps the mixed state honest.

Three real bugs found and fixed on the way, none of them cosmetic:

  * `fileSuffixIconName` returned fourteen icon names the map did not have, so
    opening a folder with a PDF, a Word document or an archive in it logged an
    error and drew a fallback glyph.  All fourteen mapped, with a test that
    walks the mapper's own return values, and another asserting every mapped
    name resolves to a Tabler component that exists.
  * `Statistic` relied on Semantic's `Statistic.Group` for its spacing, so on
    the dashboard the load averages ran together into "1 MIN. LOAD5 MIN.
    LOAD15 MIN. LOAD".  It carries its own spacing now.
  * Semantic's inverted stylesheet colors every descendant of an inverted
    segment, so Toggle labels inherited #dddddd -- white pixels in night mode,
    on the Settings page, which is exactly where someone adjusts their WROLPi
    in the dark.  Restated from tokens.

The baseline ratchet gained a third assertion: a migrated file may still lean
on Semantic's CSS classes without importing the library, which the import check
cannot see and which would collapse silently when Phase 4 deletes the
stylesheet.  There is one such bridge today and it is now declared.
2026-08-05 18:34:50 -06:00

141 lines
5.8 KiB
JavaScript

import React, {useEffect, useState} from "react";
import {Button, IconButton, Loading, Message, Modal, Radio, Stack, TextInput, toast} from "./ui";
import {addPlaylistItem, createPlaylist, fetchPlaylists} from "../api";
/**
* Modal that adds one or more items to a playlist — an existing one or a newly-created one.
*
* `items` is a list of item payloads, each ready to POST to .../items, e.g.
* {item_kind: 'file', file_group_id: 123}
* {item_kind: 'zim', zim_id: 1, zim_entry: 'A/Fire', title: 'Fire'}
* {item_kind: 'url', url: '/map?...', title: '...'}
*/
export function AddToPlaylistModal({open, onClose, items = [], onComplete}) {
const [playlists, setPlaylists] = useState(null); // null=loading, []/[...]=ok, undefined=error
const [selected, setSelected] = useState(null);
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
useEffect(() => {
if (!open) return;
setSelected(null);
setNewName('');
setPlaylists(null);
fetchPlaylists().then(setPlaylists).catch(() => setPlaylists(undefined));
}, [open]);
const addAll = async (playlistId, playlistName) => {
setAdding(true);
try {
for (const payload of items) {
await addPlaylistItem(playlistId, payload);
}
const n = items.length;
toast({
type: 'success', title: 'Added to playlist',
description: `${n} item${n === 1 ? '' : 's'} added to "${playlistName}"`, time: 3000,
});
if (onComplete) onComplete();
onClose();
} catch (e) {
// Error toast already shown by the API client.
} finally {
setAdding(false);
}
};
const handleAddExisting = async () => {
const playlist = (playlists || []).find(p => p.id === selected);
if (playlist) await addAll(playlist.id, playlist.name);
};
const handleCreateAndAdd = async () => {
const name = newName.trim();
if (!name) return;
try {
const playlist = await createPlaylist(name);
if (playlist && playlist.id) await addAll(playlist.id, playlist.name);
} catch (e) {
}
};
const creating = !!newName.trim();
return <Modal open={open} onClose={onClose} size='tiny'>
<Modal.Header>Add to Playlist</Modal.Header>
<Modal.Content>
{playlists === undefined && <Message kind='error' title='Could not load playlists'/>}
{playlists === null && <Loading/>}
{Array.isArray(playlists) && playlists.length > 0 &&
<Stack gap={4}>
{playlists.map(p => <Radio
key={p.id}
name='playlist'
label={p.name}
checked={selected === p.id}
onChange={() => setSelected(p.id)}
/>)}
</Stack>}
{Array.isArray(playlists) && playlists.length === 0 &&
<p>No playlists yet — create one below.</p>}
<form onSubmit={(e) => {
e.preventDefault();
handleCreateAndAdd();
}} style={{marginTop: '1em'}}>
<TextInput
label='Or create a new playlist'
autoFocus
placeholder='New playlist name...'
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
/>
</form>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={onClose}>Cancel</Button>
{creating
? <Button role='primary' loading={adding} onClick={handleCreateAndAdd}>Create &amp; Add</Button>
: <Button role='primary' disabled={!selected} loading={adding} onClick={handleAddExisting}>Add</Button>}
</Modal.Actions>
</Modal>;
}
/**
* A button that opens AddToPlaylistModal for one of:
* - one FileGroup (fileGroupId) or many (fileGroupIds),
* - a Zim article (zim={zimId, entry, title}), or
* - a URL (url={url, title}) — link to anything the WROLPi browser can open (e.g. a map location).
* Extra props are forwarded to the Button (size, disabled, ...).
*/
export function AddToPlaylistButton({
fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist',
title, onComplete, ...buttonProps
}) {
const [open, setOpen] = useState(false);
let items;
if (url && url.url) {
items = [{item_kind: 'url', url: url.url, title: url.title || null}];
} else if (zim && zim.zimId != null && zim.entry) {
items = [{item_kind: 'zim', zim_id: zim.zimId, zim_entry: zim.entry, title: zim.title || null}];
} else {
const ids = fileGroupIds || (fileGroupId != null ? [fileGroupId] : []);
items = ids.map(id => ({item_kind: 'file', file_group_id: id}));
}
// An empty/null `content` makes a compact icon-only button; the accessible name comes
// from `title` (kept for callers that already pass it) or falls back to the action name.
const iconOnly = !content;
return <>
{iconOnly
? <IconButton role='save' icon='list' label={title || 'Add to Playlist'} onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}/>
: <Button role='save' icon='list' onClick={() => setOpen(true)}
disabled={items.length === 0} {...buttonProps}>{content}</Button>}
<AddToPlaylistModal open={open} onClose={() => setOpen(false)} items={items}
onComplete={onComplete}/>
</>;
}