ui: Channels.js and Playlists.js off Semantic

Migrates both files onto the token-driven component library: Grid ->
Mantine Grid/Grid.Col, Segment -> Panel, Message/Header/Modal/Statistic
compounds -> ui equivalents, Semantic Form/Input -> plain form +
TextInput, and button colors -> role. Confirm/delete flows keep their
guards and now report through Button role='danger'/APIButton role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-30 13:02:57 -06:00
parent 826b37527b
commit f1056d1409
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 250 additions and 298 deletions

View file

@ -1,6 +1,5 @@
import React, {useState} from "react";
import {useHotkeys} from "react-hotkeys-hook";
import {Grid, StatisticLabel, StatisticValue,} from "semantic-ui-react";
import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo} from "../api";
import {CollectionTagModal} from "./collections/CollectionTagModal";
import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal";
@ -18,11 +17,22 @@ import {
WROLModeMessage
} from "./Common";
import {Link, useNavigate, useParams} from "react-router";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {useChannel, useChannels, useOneQuery} from "../hooks/customHooks";
import _ from "lodash";
import {Button, Form, Header, Loader, Modal, Segment, Statistic} from "./Theme";
import {toast} from "./ui";
import {
Button,
Grid,
Group,
Header,
Loading,
Message,
Modal,
Panel,
Stack,
Statistic,
StatisticGroup,
toast,
} from "./ui";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
@ -58,29 +68,16 @@ function ChannelStatistics({statistics}) {
return <></>
}
return <Segment>
return <Panel>
<Header as='h1'>Statistics</Header>
<Statistic>
<StatisticValue>{statistics.video_count}</StatisticValue>
<StatisticLabel>Videos</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.size, true)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.largest_video, true)}</StatisticValue>
<StatisticLabel>Largest Video</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{secondsToFullDuration(statistics.length)}</StatisticValue>
<StatisticLabel>Total Duration</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanNumber(statistics.video_tags)}</StatisticValue>
<StatisticLabel>Video Tags</StatisticLabel>
</Statistic>
</Segment>
<StatisticGroup>
<Statistic value={statistics.video_count} label='Videos'/>
<Statistic value={humanFileSize(statistics.size, true)} label='Total Size'/>
<Statistic value={humanFileSize(statistics.largest_video, true)} label='Largest Video'/>
<Statistic value={secondsToFullDuration(statistics.length)} label='Total Duration'/>
<Statistic value={humanNumber(statistics.video_tags)} label='Video Tags'/>
</StatisticGroup>
</Panel>
}
@ -97,7 +94,7 @@ export function ChannelEditPage() {
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
if (!form.ready) {
return <Loader active/>;
return <Loading/>;
}
const handleRefreshChannel = async (e) => {
@ -169,7 +166,7 @@ export function ChannelEditPage() {
};
const deleteButton = <APIButton
color='red'
role='danger'
size='small'
confirmContent='Are you sure you want to delete this channel? No video files will be deleted.'
confirmButton='Delete'
@ -180,7 +177,7 @@ export function ChannelEditPage() {
>Delete</APIButton>;
const refreshButton = <APIButton
color='blue'
role='primary'
size='small'
onClick={handleRefreshChannel}
obeyWROLMode={true}
@ -191,13 +188,13 @@ export function ChannelEditPage() {
type="button"
size='small'
onClick={() => setTagEditModalOpen(true)}
color='violet'
role='primary'
style={{marginTop: '1em'}}
>Tag</Button>;
const reorganizeButton = (
<APIButton
color='orange'
role='retry'
size='small'
onClick={() => setReorganizeModalOpen(true)}
obeyWROLMode={true}
@ -222,8 +219,7 @@ export function ChannelEditPage() {
</Link>
{channel?.needs_reorganization && (
<Message warning>
<Message.Header>File Format Changed</Message.Header>
<Message kind='warning' title='File Format Changed'>
<p>
The file name format has changed. Click "Reorganize Files" to move existing files
to match the new format.
@ -239,8 +235,8 @@ export function ChannelEditPage() {
appliedTagName={channel?.tag_name}
onSubmit={handleSubmit}
>
<Grid.Row>
<Grid.Column width={8}>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<InputForm
form={form}
label="Channel Name"
@ -248,8 +244,8 @@ export function ChannelEditPage() {
placeholder="Short Channel Name"
required={true}
/>
</Grid.Column>
<Grid.Column width={8}>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<DestinationForm
form={form}
label='Directory'
@ -257,38 +253,30 @@ export function ChannelEditPage() {
path='directory'
required={true}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
</Grid.Col>
<Grid.Col span={12}>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Column>
</Grid.Row>
{(channel.url || channel.rss_url) &&
<Grid.Row>
<Grid.Column>
</Grid.Col>
{(channel.url || channel.rss_url) &&
<Grid.Col span={12}>
<SimpleAccordion title='Details'>
<Grid>
{channel.url && <Grid.Row columns={1}>
<Grid.Column>
<Header as='h4'>URL</Header>
<a href={channel.url}>{channel.url}</a>
</Grid.Column>
</Grid.Row>}
{channel.rss_url && <Grid.Row columns={1}>
<Grid.Column>
<Header as='h4'>RSS URL</Header>
<a href={channel.rss_url}>{channel.rss_url}</a>
</Grid.Column>
</Grid.Row>}
</Grid>
<Stack gap='sm'>
{channel.url && <div>
<Header as='h4'>URL</Header>
<a href={channel.url}>{channel.url}</a>
</div>}
{channel.rss_url && <div>
<Header as='h4'>RSS URL</Header>
<a href={channel.rss_url}>{channel.rss_url}</a>
</div>}
</Stack>
</SimpleAccordion>
</Grid.Column>
</Grid.Row>}
</Grid.Col>}
</Grid>
</CollectionEditForm>
{/* Tag Modal */}
@ -314,22 +302,14 @@ export function ChannelEditPage() {
/>
{/* Downloads Segment */}
<Segment>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<Header as='h1'>Downloads</Header>
</Grid.Column>
<Grid.Column>
<Button floated='right'
onClick={() => setDownloadModalOpen(!downloadModalOpen)}
>
New Download
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
<Modal open={downloadModalOpen} closeIcon onClose={() => setDownloadModalOpen(false)}>
<Panel>
<Group justify='space-between' align='center' wrap='wrap'>
<Header as='h1'>Downloads</Header>
<Button onClick={() => setDownloadModalOpen(!downloadModalOpen)}>
New Download
</Button>
</Group>
<Modal open={downloadModalOpen} onClose={() => setDownloadModalOpen(false)}>
<Modal.Content>
<Header as='h2'>New Channel Download</Header>
<ChannelDownloadForm
@ -342,7 +322,7 @@ export function ChannelEditPage() {
</Modal>
<RecurringDownloadsTable downloads={channel?.downloads} fetchDownloads={fetchChannel} onDelete={onDelete}/>
</Segment>
</Panel>
{channel && channel.statistics && <ChannelStatistics statistics={channel.statistics}/>}
</>;
@ -434,88 +414,74 @@ export function ChannelNewPage() {
let messageRow;
if (error || success) {
messageRow = <Grid.Row columns={1}>
<Grid.Column>
{error &&
<Message negative
header={messageHeader}
content={messageContent}
/>}
{success &&
<Message positive
header={messageHeader}
content={messageContent}
/>}
</Grid.Column>
</Grid.Row>
messageRow = <Grid.Col span={12}>
{error &&
<Message kind='error' title={messageHeader}>{messageContent}</Message>}
{success &&
<Message kind='success' title={messageHeader}>{messageContent}</Message>}
</Grid.Col>
}
return <>
<BackButton/>
<Segment>
<Panel>
<Header as="h1">New Channel</Header>
<WROLModeMessage content='Channel creation is disabled while WROL Mode is enabled.'/>
<Form
<form
id="newChannel"
error={error}
success={success}
autoComplete="off"
onSubmit={e => e.preventDefault()}
>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<InputForm
form={form}
label="Channel Name"
name="name"
placeholder="Short Channel Name"
required={true}
/>
</Grid.Column>
<Grid.Column>
<DestinationForm
form={form}
label='Directory'
name='directory'
path='directory'
required={true}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<InputForm
form={form}
label="Channel Name"
name="name"
placeholder="Short Channel Name"
required={true}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<DestinationForm
form={form}
label='Directory'
name='directory'
path='directory'
required={true}
/>
</Grid.Col>
<Grid.Col span={12}>
<ToggleForm
form={form}
label={downloadMissingDataLabel}
path='download_missing_data'
/>
</Grid.Col>
{messageRow}
<Grid.Row>
<Grid.Column width={8}>
<DownloadTagsSelector
form={form}
limit={1}
path='tag_name'
name='tag_name'
/>
</Grid.Column>
<Grid.Column>
<Grid.Col span={{base: 12, sm: 8}}>
<DownloadTagsSelector
form={form}
limit={1}
path='tag_name'
name='tag_name'
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 4}}>
<div style={{textAlign: 'right'}}>
<APIButton
color='violet'
role='primary'
size='big'
floated='right'
onClick={handleSubmit}
disabled={form.disabled}
obeyWROLMode={true}
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</div>
</Grid.Col>
</Grid>
</Form>
</Segment>
</form>
</Panel>
</>;
}
@ -534,37 +500,28 @@ export function ChannelsPage() {
}, {enableOnFormTags: false});
// Header section matching DomainsPage pattern
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(channels) || channels.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Link to='/videos/channel/new'>
<Button secondary>New Channel</Button>
</Link>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
const header = <Group justify='space-between' align='flex-end' wrap='wrap' mb='1em'>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(channels) || channels.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
<Link to='/videos/channel/new'>
<Button role='primary'>New Channel</Button>
</Link>
</Group>;
// Empty state
if (channels && channels.length === 0) {
return <>
{header}
<Message>
<Message.Header>No channels exist yet!</Message.Header>
<Message.Content><Link to='/videos/channel/new'>Create one.</Link></Message.Content>
<Message title='No channels exist yet!'>
<Link to='/videos/channel/new'>Create one.</Link>
</Message>
</>;
}

View file

@ -1,9 +1,19 @@
import React, {useEffect, useState} from "react";
import {Link, Route, Routes, useNavigate, useParams} from "react-router";
import {Grid, Image, Input, Message} from "semantic-ui-react";
import {toast} from "./ui";
import {Button, Form, Header, Icon, Loader, Modal, Segment, Table} from "./Theme";
import {IconMapPin} from "@tabler/icons-react";
import {
Button,
Group,
Header,
Icon,
Loading,
Message,
Modal,
Panel,
Table,
TextInput,
toast,
} from "./ui";
import {
APIButton,
BackButton,
@ -35,7 +45,6 @@ import {
setPlaylistTag,
updatePlaylist,
} from "../api";
import {ThemeContext} from "../contexts/contexts";
import {TagsSelector} from "../Tags";
@ -119,39 +128,36 @@ export function PlaylistsPage() {
}
};
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(playlists) || playlists.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Button secondary onClick={() => setModalOpen(true)}>New Playlist</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
const header = <Group justify='space-between' align='flex-end' wrap='wrap' mb='1em'>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(playlists) || playlists.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
<Button role='primary' onClick={() => setModalOpen(true)}>New Playlist</Button>
</Group>;
const createModal = <Modal open={modalOpen} onClose={() => setModalOpen(false)} size='tiny'>
<Modal.Header>New Playlist</Modal.Header>
<Modal.Content>
<Form onSubmit={handleCreate}>
<Form.Field>
<label>Name</label>
<Input autoFocus placeholder='Playlist name...' value={name}
onChange={(e, {value}) => setName(value)}/>
</Form.Field>
<Form.Field>
<label>
<form onSubmit={e => {
e.preventDefault();
handleCreate();
}}>
<TextInput
autoFocus
label='Name'
placeholder='Playlist name...'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
<div style={{marginTop: '1em'}}>
<label style={{display: 'flex', alignItems: 'center', gap: '0.3em', marginBottom: '0.3em'}}>
Tag
<InfoPopup content='Optional. A tagged playlist lives under its tag in the
Playlists Directory.'/>
@ -162,12 +168,12 @@ export function PlaylistsPage() {
onAdd={setTagName}
onRemove={() => setTagName(null)}
/>
</Form.Field>
</Form>
</div>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setModalOpen(false)}>Cancel</Button>
<Button primary disabled={!name.trim()} onClick={handleCreate}>Create</Button>
<Button role='cancel' onClick={() => setModalOpen(false)}>Cancel</Button>
<Button role='save' disabled={!name.trim()} onClick={handleCreate}>Create</Button>
</Modal.Actions>
</Modal>;
@ -185,22 +191,24 @@ export function PlaylistsPage() {
}
// Icon (name + color) for a playlist item. File items use the FileGroup's mimetype/model icon
// and color (video, pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed.
function itemIcon(item) {
// Icon for a playlist item. File items use FileGroup's mimetype/model icon and color (video,
// pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed.
function ItemIcon({item}) {
if (item.item_kind === 'file' && item.file_group) {
const fg = item.file_group;
const lowerPath = (fg.primary_path || '').toLowerCase();
return {name: mimetypeIconName(fg.mimetype, lowerPath), color: mimetypeColor(fg.mimetype, lowerPath)};
const name = mimetypeIconName(fg.mimetype, lowerPath);
const color = mimetypeColor(fg.mimetype, lowerPath);
return <Icon name={name} size='large' style={{color: `var(--${color})`}}/>;
}
if (item.item_kind === 'zim') return {name: 'book'};
if (item.item_kind === 'zim') return <Icon name='book' size='large'/>;
if (item.item_kind === 'url') {
// A map-location URL (e.g. /map?lat=&lon=) gets a map marker; other URLs get the link icon.
const u = (item.url || '').toLowerCase();
if (u.startsWith('/map')) return {name: 'map marker alternate'};
return {name: 'linkify'};
if (u.startsWith('/map')) return <Icon component={IconMapPin} size='large'/>;
return <Icon name='linkify' size='large'/>;
}
return {name: 'file'};
return <Icon name='file' size='large'/>;
}
@ -278,13 +286,13 @@ function ItemTitle({item, label}) {
// The ordered items table, shared by the view and edit pages. When `editable`, the reorder/remove
// action buttons (and their column) are shown; otherwise the table is read-only.
function PlaylistItemsTable({items, editable, onMove, onRemove}) {
return <Table unstackable celled>
return <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell collapsing>#</Table.HeaderCell>
<Table.HeaderCell collapsing>Item</Table.HeaderCell>
<Table.HeaderCell>#</Table.HeaderCell>
<Table.HeaderCell>Item</Table.HeaderCell>
<Table.HeaderCell>Title</Table.HeaderCell>
{editable && <Table.HeaderCell collapsing/>}
{editable && <Table.HeaderCell/>}
</Table.Row>
</Table.Header>
<Table.Body>
@ -292,23 +300,23 @@ function PlaylistItemsTable({items, editable, onMove, onRemove}) {
const label = itemLabel(item);
const poster = item.item_kind === 'file' && item.file_group
? findPosterPath(item.file_group) : null;
return <Table.Row key={item.id} verticalAlign='middle'>
<Table.Cell collapsing>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell collapsing textAlign='center' style={{width: '90px'}}>
return <Table.Row key={item.id}>
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
{poster
? <Image src={`/media/${encodeMediaPath(poster)}`} alt=''
centered style={{maxHeight: '45px', maxWidth: '80px', width: 'auto'}}/>
: <Icon {...itemIcon(item)} size='large'/>}
? <img alt='' src={`/media/${encodeMediaPath(poster)}`}
style={{maxHeight: '45px', maxWidth: '80px', width: 'auto', display: 'block', margin: '0 auto'}}/>
: <ItemIcon item={item}/>}
</Table.Cell>
<Table.Cell>
<ItemTitle item={item} label={label}/>
</Table.Cell>
{editable && <Table.Cell collapsing textAlign='right'>
<Button icon='arrow up' size='mini' disabled={index === 0}
{editable && <Table.Cell style={{textAlign: 'right', whiteSpace: 'nowrap'}}>
<Button icon='arrow up' size='xs' disabled={index === 0}
onClick={() => onMove(index, -1)}/>
<Button icon='arrow down' size='mini' disabled={index === items.length - 1}
<Button icon='arrow down' size='xs' disabled={index === items.length - 1}
onClick={() => onMove(index, 1)}/>
<Button icon='trash' color='red' size='mini'
<Button role='danger' icon='trash' size='xs'
onClick={() => onRemove(item.id)}/>
</Table.Cell>}
</Table.Row>;
@ -323,7 +331,6 @@ function PlaylistItemsTable({items, editable, onMove, onRemove}) {
export function PlaylistViewPage() {
const {playlistId} = useParams();
const {playlist} = usePlaylist(playlistId);
const {t} = React.useContext(ThemeContext);
useTitle(playlist && playlist.name ? `${playlist.name} Playlist` : 'Playlist');
@ -331,7 +338,7 @@ export function PlaylistViewPage() {
return <ErrorMessage>Could not fetch playlist</ErrorMessage>;
}
if (playlist === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
const items = playlist.items || [];
@ -345,10 +352,10 @@ export function PlaylistViewPage() {
<Icon name='edit' style={{marginLeft: '0.5em'}}/>
</Link>
</Header>
{playlist.description && <p {...t}>{playlist.description}</p>}
{playlist.description && <p>{playlist.description}</p>}
{items.length === 0
? <Message><Message.Header>This playlist is empty</Message.Header></Message>
? <Message title='This playlist is empty'/>
: <PlaylistItemsTable items={items} editable={false}/>}
</>;
}
@ -385,7 +392,7 @@ export function PlaylistEditPage() {
return <ErrorMessage>Could not fetch playlist</ErrorMessage>;
}
if (playlist === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
const items = playlist.items || [];
@ -463,7 +470,7 @@ export function PlaylistEditPage() {
};
const deleteButton = <APIButton
color='red'
role='danger'
size='small'
confirmContent='Are you sure you want to delete this playlist? Its directory will be removed.'
confirmButton='Delete'
@ -477,7 +484,7 @@ export function PlaylistEditPage() {
type="button"
size='small'
onClick={() => setTagModalOpen(true)}
color='violet'
role='primary'
disabled={!editable}
style={{marginTop: '1em'}}
>Tag</Button>;
@ -504,47 +511,35 @@ export function PlaylistEditPage() {
appliedTagName={playlist.tag_name}
onSubmit={handleSave}
>
<Grid.Row>
<Grid.Column width={8}>
<Form.Field>
<label>Playlist Name</label>
<Input
placeholder='Playlist name...'
value={name}
disabled={!editable}
onChange={(e, {value}) => setName(value)}
/>
</Form.Field>
</Grid.Column>
<Grid.Column width={8}>
<Form.Field>
<label>
Directory
<InfoPopup content='Where the playlist lives on disk. By default it is
managed automatically in the Playlists Directory (under its tag, if
tagged); choose a different directory to manage it manually.'/>
</label>
<DirectorySearch
value={directory}
disabled={!editable}
onSelect={value => setDirectory(value || '')}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Form.Field>
<label>Description</label>
<Input
placeholder='Optional description...'
value={description}
disabled={!editable}
onChange={(e, {value}) => setDescription(value)}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<TextInput
label='Playlist Name'
placeholder='Playlist name...'
value={name}
disabled={!editable}
onChange={(e) => setName(e.currentTarget.value)}
/>
<div style={{marginTop: '1em'}}>
<label style={{display: 'flex', alignItems: 'center', gap: '0.3em', marginBottom: '0.3em'}}>
Directory
<InfoPopup content='Where the playlist lives on disk. By default it is
managed automatically in the Playlists Directory (under its tag, if
tagged); choose a different directory to manage it manually.'/>
</label>
<DirectorySearch
value={directory}
disabled={!editable}
onSelect={value => setDirectory(value || '')}
/>
</div>
<div style={{marginTop: '1em'}}>
<TextInput
label='Description'
placeholder='Optional description...'
value={description}
disabled={!editable}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
</div>
</CollectionEditForm>
{/* Tag Modal */}
@ -559,34 +554,34 @@ export function PlaylistEditPage() {
/>
{/* Items Segment */}
<Segment>
<Panel>
<Header as='h1'>Items</Header>
{items.length === 0
? <Message>
<Message.Header>This playlist is empty</Message.Header>
{editable && <Message.Content>Add a link below.</Message.Content>}
? <Message title='This playlist is empty'>
{editable && 'Add a link below.'}
</Message>
: <PlaylistItemsTable items={items} editable={editable} onMove={move} onRemove={handleRemove}/>}
<Header as='h4'>Add a link</Header>
<Form onSubmit={handleAddUrl}>
<Form.Group>
<Form.Field width={8}>
<Input placeholder='URL (e.g. /map?lat=40.76&lon=-111.89&z=10)'
value={url} disabled={!editable}
onChange={(e, {value}) => setUrl(value)}/>
</Form.Field>
<Form.Field width={6}>
<Input placeholder='Title (optional)' value={urlTitle} disabled={!editable}
onChange={(e, {value}) => setUrlTitle(value)}/>
</Form.Field>
<Form.Field>
<Button primary type='submit' disabled={!editable || !url.trim()}>Add</Button>
</Form.Field>
</Form.Group>
</Form>
</Segment>
<form onSubmit={e => {
e.preventDefault();
handleAddUrl();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
placeholder='URL (e.g. /map?lat=40.76&lon=-111.89&z=10)'
value={url} disabled={!editable}
onChange={(e) => setUrl(e.currentTarget.value)}/>
<TextInput
style={{flex: '1 1 200px'}}
placeholder='Title (optional)' value={urlTitle} disabled={!editable}
onChange={(e) => setUrlTitle(e.currentTarget.value)}/>
<Button role='primary' type='submit' disabled={!editable || !url.trim()}>Add</Button>
</Group>
</form>
</Panel>
</>;
}