wrolpi/app/src/components/Channels.js
2023-01-07 18:53:22 -07:00

580 lines
20 KiB
JavaScript

import React, {useState} from "react";
import {
AccordionContent,
AccordionTitle,
Grid,
Input,
StatisticLabel,
StatisticValue,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "semantic-ui-react";
import {createChannel, deleteChannel, downloadChannel, refreshChannel, updateChannel, validateRegex} from "../api";
import {
BackButton,
DirectoryInput,
frequencyOptions,
humanFileSize,
isoDatetimeToString,
RequiredAsterisk,
secondsToFrequency,
Toggle,
useTitle,
WROLModeMessage
} from "./Common";
import {Link, useNavigate, useParams} from "react-router-dom";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import Confirm from "semantic-ui-react/dist/commonjs/addons/Confirm";
import {ChannelPlaceholder} from "./Placeholder";
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
import {useChannel, useChannels} from "../hooks/customHooks";
import {toast} from "react-semantic-toasts";
import _ from "lodash";
import {
Accordion,
Button,
Form,
FormField,
FormGroup,
FormInput,
Header,
Loader,
Segment,
Statistic,
Table
} from "./Theme";
import Dropdown from "semantic-ui-react/dist/commonjs/modules/Dropdown";
import {Media} from "../contexts/contexts";
function ChannelStatistics({statistics}) {
if (!statistics) {
return <></>
}
return (
<Segment>
<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>
</Segment>
)
}
function ChannelPage({create, header}) {
const [disabled, setDisabled] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [validRegex, setValidRegex] = useState(true);
const [activeIndex, setActiveIndex] = useState(-1);
const [errors, setErrors] = useState({});
const [error, setError] = useState(false);
const [success, setSuccess] = useState(false);
const [messageHeader, setMessageHeader] = useState();
const [messageContent, setMessageContent] = useState();
const navigate = useNavigate();
const {channelId} = useParams();
const {channel, changeValue, fetchChannel} = useChannel(channelId);
let title;
if (channel && channel.name) {
title = channel.name;
}
useTitle(title);
if (!channel) {
return <Loader active/>;
}
const checkRegex = async (e, {value}) => {
changeValue('match_regex', value);
const valid = await validateRegex(value);
setValidRegex(valid);
}
const handleCheckbox = (e, {name, checked}) => {
if (e) {
e.preventDefault();
}
changeValue(name, checked);
}
const handleInputChange = (e, {name, value}) => {
if (e) {
e.preventDefault();
}
changeValue(name, value);
}
const setErrorMessage = (header, message) => {
setError(true);
setSuccess(false);
setMessageHeader(header);
setMessageContent(message);
}
const setSuccessMessage = (header, message) => {
setError(false);
setSuccess(true);
setMessageHeader(header);
setMessageContent(message);
}
const handleSubmit = async (e) => {
e.preventDefault();
const body = {
name: channel.name,
directory: channel.directory,
mkdir: channel.mkdir,
url: channel.url,
download_frequency: channel.download_frequency,
match_regex: channel.match_regex,
};
setDisabled(true);
let response = null;
try {
if (create !== undefined) {
response = await createChannel(body);
} else {
response = await updateChannel(channelId, body);
}
} catch (e) {
console.error(e);
toast({
type: 'error',
title: 'Unexpected server response',
description: 'Could not save channel',
time: 5000,
});
return;
} finally {
setDisabled(false);
}
if (response && response.status >= 200 && response.status < 300) {
let location = response.headers.get('Location');
let channelResponse = await fetch(location);
let data = await channelResponse.json();
let channel = data['channel'];
if (response.status === 201) {
setSuccessMessage(
'Channel created',
<span>
Your channel was created. View it <Link to={`/videos/channel/${channel.id}/edit`}>here</Link>
</span>
);
} else {
await fetchChannel();
setSuccessMessage('Channel updated', 'Your channel was updated');
}
} else if (response) {
// Some error occurred.
let error = await response.json();
let cause = error.cause;
if (error.code === 3 || (cause && cause.code === 3)) {
setErrorMessage(
'Invalid directory',
'This directory does not exist.',
'directory',
);
} else if (cause && cause.code === 7) {
setErrorMessage(
'Invalid directory',
'This directory is already used by another channel',
'directory',
);
} else if (cause && cause.code === 5) {
setErrorMessage(
'Invalid name',
'This channel name is already taken',
'name',
);
} else {
setErrorMessage('Invalid channel', 'Unable to save channel. See logs.');
}
} else {
console.error('Did not get a response for channel!');
}
}
const handleAdvancedClick = (e, {index}) => {
setActiveIndex(activeIndex === index ? -1 : index);
}
const handleDownloadChannel = async (e) => {
e.preventDefault();
return await downloadChannel(channelId);
}
const handleRefreshChannel = async (e) => {
e.preventDefault();
return await refreshChannel(channelId);
}
const handleDeleteConfirm = async () => {
setDeleteOpen(false);
let response = await deleteChannel(channelId);
if (response.status === 204) {
navigate('/videos/channel');
} else {
setSuccessMessage('Failed to delete', 'Failed to delete this channel, check logs.');
}
}
const handleDeleteButton = (e) => {
e.preventDefault();
setDeleteOpen(true);
}
return <>
<BackButton/>
{!create &&
<Link to={`/videos/channel/${channel.id}/video`}>
<Button>Videos</Button>
</Link>}
<Segment>
<Header as="h1">{header}</Header>
<WROLModeMessage content='Channel page is disabled while WROL Mode is enabled.'/>
<Form
id="editChannel"
onSubmit={handleSubmit}
error={error}
success={success}
autoComplete="off"
>
<FormGroup>
<FormField width={8}>
<FormInput required
label="Channel Name"
name="name"
type="text"
placeholder="Short Channel Name"
disabled={disabled}
error={errors.name}
value={channel.name}
onChange={(e, {value}) => changeValue('name', value)}
/>
</FormField>
<FormField width={8}>
<label>
Directory <RequiredAsterisk/>
</label>
<DirectoryInput required
disabled={create === undefined}
value={channel.directory}
setInput={value => changeValue('directory', value)}
placeholder='videos/channel directory'
/>
</FormField>
</FormGroup>
{
create !== undefined &&
<FormGroup>
<FormField width={8}/>{/* Empty field*/}
<FormField width={8}>
<FormField>
<Toggle
toggle
label="Create this directory, if it doesn't exist."
name="mkdir"
disabled={disabled}
error={errors.mkdir}
checked={channel.mkdir}
onChange={i => {
handleCheckbox(null, {name: 'mkdir', checked: i})
}}
/>
</FormField>
</FormField>
</FormGroup>
}
<FormGroup>
<FormField width={16}>
<FormInput
label="URL"
name="url"
type="url"
disabled={disabled}
placeholder='https://example.com/channel/videos'
error={errors.url}
value={channel.url}
onChange={handleInputChange}
/>
</FormField>
</FormGroup>
<FormGroup>
<FormField>
<label>Download Frequency</label>
<Dropdown selection
name='download_frequency'
placeholder='Frequency'
error={errors.download_frequency}
value={channel.download_frequency}
disabled={disabled || !channel.url}
options={frequencyOptions}
onChange={handleInputChange}
/>
</FormField>
</FormGroup>
<Accordion style={{marginBottom: '1em'}}>
<AccordionTitle
onClick={handleAdvancedClick}
index={0}
active={activeIndex === 0}
>
<Icon name='dropdown'/>
Advanced Settings
</AccordionTitle>
<AccordionContent active={activeIndex === 0}>
<Segment secondary>
<Header as="h4">
The following settings are encouraged by default, modify them at your own risk.
</Header>
<FormField>
<FormInput
label="Title Match Regex"
name="match_regex"
type="text"
disabled={disabled}
error={!validRegex}
placeholder='.*([Nn]ame Matching).*'
value={channel.match_regex}
onChange={checkRegex}
/>
</FormField>
</Segment>
</AccordionContent>
</Accordion>
<Message error
header={messageHeader}
content={messageContent}
/>
<Message success
header={messageHeader}
content={messageContent}
/>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
{!create &&
<>
<Button color='red' onClick={handleDeleteButton} size='small'>
Delete
</Button>
<Confirm
open={deleteOpen}
content='Are you sure you want to delete this channel? No video files will be deleted.'
confirmButton='Delete'
onCancel={() => setDeleteOpen(false)}
onConfirm={handleDeleteConfirm}
/>
<Button
color='violet'
size='small'
onClick={handleDownloadChannel}
disabled={!channel.url || !channel.download_frequency}
>
Download
</Button>
<Button color='blue' size='small' onClick={handleRefreshChannel}>
Refresh
</Button>
</>
}
</Grid.Column>
<Grid.Column>
<Button
color="green"
size='big'
type="submit"
floated='right'
>
{disabled ? <Loader active inline/> : 'Save'}
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>
<div style={{marginTop: '2em'}}>
{channel.statistics && <ChannelStatistics statistics={channel.statistics}/>}
</div>
</>
}
export function EditChannel(props) {
return <ChannelPage header="Edit Channel" {...props}/>;
}
export function NewChannel(props) {
useTitle('New Channel');
return <ChannelPage header='New Channel' {...props} create/>
}
function ChannelRow({channel}) {
const navigate = useNavigate();
const editTo = `/videos/channel/${channel.id}/edit`;
const videosTo = `/videos/channel/${channel.id}/video`;
return <TableRow>
<TableCell>
<Link to={videosTo}>{channel.name}</Link>
</TableCell>
<TableCell>
{channel.video_count}
</TableCell>
<TableCell>
{channel.url && channel.info_date ? isoDatetimeToString(channel.info_date) : null}
</TableCell>
<TableCell>
{channel.url && channel.download_frequency ? secondsToFrequency(channel.download_frequency) : null}
</TableCell>
<TableCell textAlign='right'>
<Button secondary onClick={() => navigate(editTo)}>Edit</Button>
</TableCell>
</TableRow>;
}
function MobileChannelRow({channel}) {
const editTo = `/videos/channel/${channel.id}/edit`;
const videosTo = `/videos/channel/${channel.id}/video`;
return <TableRow verticalAlign='top'>
<TableCell width={10} colSpan={2}>
<Link as='h3' to={videosTo}>
<h3>
{channel.name}
</h3>
</Link>
<p>
Videos: {channel.video_count}
</p>
</TableCell>
<TableCell width={6} colSpan={2} textAlign='right'>
<p>
<Link className="ui button secondary" to={editTo}>Edit</Link>
</p>
</TableCell>
</TableRow>;
}
export function Channels() {
useTitle('Channels');
const {channels} = useChannels();
const [searchStr, setSearchStr] = useState('');
const handleInputChange = (e, {value}) => {
e.preventDefault();
setSearchStr(value);
}
let header = <div style={{marginBottom: '1em'}}>
<Header as='h1'>Channels</Header>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<Input fluid
icon='search'
placeholder='Name filter...'
size="large"
name="filterStr"
value={searchStr}
onChange={handleInputChange}/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Link to='/videos/channel/new'>
<Button secondary>New Channel</Button>
</Link>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
let tableHeader = <TableHeader>
<TableRow>
<TableHeaderCell width={8}>Name</TableHeaderCell>
<TableHeaderCell width={2}>Videos</TableHeaderCell>
<TableHeaderCell width={2}>Last Update</TableHeaderCell>
<TableHeaderCell width={2}>Frequency</TableHeaderCell>
<TableHeaderCell width={2} colSpan={3} textAlign='center'>Manage</TableHeaderCell>
</TableRow>
</TableHeader>;
if (channels === null) {
// Placeholders while fetching
return <>
{header}
<Table compact striped basic size='large'>
{tableHeader}
<TableBody>
<TableRow>
<TableCell><ChannelPlaceholder/></TableCell>
<TableCell/>
<TableCell/>
<TableCell/>
</TableRow>
</TableBody>
</Table>
</>
} else if (_.isEmpty(channels)) {
return <>
{header}
<Message>
<Message.Header>No channels exist yet!</Message.Header>
<Message.Content><Link to='/videos/channel/new'>Create one.</Link></Message.Content>
</Message>
</>
}
let filteredChannels = channels;
if (searchStr) {
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
filteredChannels = channels.filter(i => re.test(i['name']));
}
return <>
{header}
<Media at='mobile'>
<Table striped unstackable size='small'>
<TableBody>
{filteredChannels.map(channel => <MobileChannelRow key={channel.id} channel={channel}/>)}
</TableBody>
</Table>
</Media>
<Media greaterThanOrEqual='tablet'>
<Table compact unstackable striped size='large'>
{tableHeader}
<TableBody>
{filteredChannels.map(channel => <ChannelRow key={channel.id} channel={channel}/>)}
</TableBody>
</Table>
</Media>
</>
}