mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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.
141 lines
5.8 KiB
JavaScript
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 & 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}/>
|
|
</>;
|
|
}
|