ui: Phase 2 complete — Semantic UI is gone (#588)

ui: Phase 2 complete — Semantic UI is gone
This commit is contained in:
lrnselfreliance 2026-07-30 14:35:27 -06:00 • committed by GitHub
commit 3a8b73fd3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2741 additions and 3357 deletions

View file

@ -1,3 +1,47 @@
/* Nav bar: a hand-rolled flexbox bar (was a Semantic Menu). The user-chosen
background color and the `--btn-text` foreground are set inline per theme;
this stylesheet only supplies structure and hover affordances. */
.wrolpi-navbar {
display: flex;
align-items: stretch;
/*
* Wraps rather than clipping. Semantic's menu wrapped, and with this many items the
* bar overflows a 1000px viewport -- `nowrap` cut "Admin" off the right edge, which
* is the one link a user needs to reach the settings that fix a broken WROLPi.
*/
flex-wrap: wrap;
min-height: 3.4em;
padding: 0 0.25em;
}
.wrolpi-navbar-right {
display: flex;
align-items: center;
margin-left: auto;
gap: 0.2em;
}
.wrolpi-navbar-link,
.wrolpi-navbar-link-button {
display: flex;
align-items: center;
/* Tightened from 1.1em: the bar has eleven links plus nine indicators. */
padding: 0 0.8em;
color: inherit;
text-decoration: none;
white-space: nowrap;
background: none;
border: none;
font: inherit;
cursor: pointer;
}
.wrolpi-navbar-link:hover,
.wrolpi-navbar-link-button:hover {
/* A translucent black works on every navbar colour, light or dark. */
background: rgba(0, 0, 0, 0.12);
}
.channel-edit {
border-radius: 3px;
padding: 0.75em;

View file

@ -5,7 +5,6 @@ import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, Rout
import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos";
import AdminRoute from "./components/admin/AdminRoute";
import {Box, Header} from "./components/ui";
import 'semantic-ui-offline/semantic.min.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
// Theme tokens load last so they win over both component libraries.

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,7 @@
import React from 'react';
import {Button, Divider, Header, Loader, Modal, Progress} from "./Theme";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Button, Divider, Header, Loading, Message, Modal, Progress} from "./ui";
import {applyBulkTags, getBulkTagPreview, getBulkTagProgress} from "../api";
import {TagsContext} from "../Tags";
import {ThemeContext} from "../contexts/contexts";
const BULK_TAG_WARNING_THRESHOLD = 50;
const POLL_INTERVAL = 500; // Poll progress every 500ms
@ -18,7 +16,6 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
const [error, setError] = React.useState(null);
const {tagNames, TagsGroup} = React.useContext(TagsContext);
const {t} = React.useContext(ThemeContext);
// Fetch preview when modal opens
React.useEffect(() => {
@ -132,16 +129,13 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
<Modal.Header>Bulk Tag Files</Modal.Header>
<Modal.Content>
{state === 'loading' && (
<div style={{textAlign: 'center', padding: '2em'}}>
<Loader active inline="centered">Loading preview...</Loader>
</div>
<Loading>Loading preview...</Loading>
)}
{state === 'preview' && (
<>
{error && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p>{error}</p>
</Message>
)}
@ -151,8 +145,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
</Header>
{showWarning && (
<Message warning>
<Message.Header>Warning</Message.Header>
<Message kind='warning' title='Warning'>
<p>Tagging over {BULK_TAG_WARNING_THRESHOLD} files may make it difficult to find what you are looking for. Are you sure?</p>
</Message>
)}
@ -163,7 +156,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{localTags.length > 0 ? (
<TagsGroup tagNames={localTags} onClick={removeTag}/>
) : (
<p {...t}>Add one or more tags below</p>
<p>Add one or more tags below</p>
)}
<Divider/>
@ -171,7 +164,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{unusedTags.length > 0 ? (
<TagsGroup tagNames={unusedTags} onClick={addTag}/>
) : (
<p {...t}>You have no tags</p>
<p>You have no tags</p>
)}
</>
)}
@ -181,18 +174,14 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
<Header as="h4">Applying tags...</Header>
<Progress
percent={progressPercent}
progress
indicating
color="blue"
>
<span {...t}>{progress.completed} / {progress.total} files</span>
</Progress>
label={`${progress.completed} / ${progress.total} files`}
/>
{progress.queued_jobs > 0 && (
<p {...t}>{progress.queued_jobs} job{progress.queued_jobs !== 1 ? 's' : ''} queued</p>
<p>{progress.queued_jobs} job{progress.queued_jobs !== 1 ? 's' : ''} queued</p>
)}
{error && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p>{error}</p>
</Message>
)}
@ -201,13 +190,11 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
{state === 'complete' && (
<div style={{padding: '1em'}}>
<Message positive>
<Message.Header>Complete</Message.Header>
<Message kind='success' title='Complete'>
<p>Successfully tagged {progress.total} file{progress.total !== 1 ? 's' : ''}.</p>
</Message>
{error && (
<Message warning>
<Message.Header>Warning</Message.Header>
<Message kind='warning' title='Warning'>
<p>Some errors occurred: {error}</p>
</Message>
)}
@ -217,7 +204,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
<Modal.Actions>
{state === 'preview' && (
<>
<Button onClick={handleClose}>Cancel</Button>
<Button role='cancel' onClick={handleClose}>Cancel</Button>
<Button
color="violet"
onClick={handleApply}
@ -228,12 +215,12 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
</>
)}
{state === 'applying' && (
<Button onClick={handleClose} disabled>
<Button role='cancel' onClick={handleClose} disabled>
Please wait...
</Button>
)}
{state === 'complete' && (
<Button color="green" onClick={handleClose}>
<Button role='save' onClick={handleClose}>
Done
</Button>
)}

View file

@ -1,7 +1,8 @@
import React from 'react';
import {act, render, screen, waitFor} from '@testing-library/react';
import {act, screen, waitFor} from '@testing-library/react';
import {BulkTagModal} from './BulkTagModal';
import {TagsContext} from '../Tags';
import {renderWithProviders} from '../test-utils';
// Mock the API functions
jest.mock('../api', () => ({
@ -10,42 +11,6 @@ jest.mock('../api', () => ({
getBulkTagProgress: jest.fn(),
}));
// Mock Theme components with compound patterns
jest.mock('./Theme', () => {
const MockModal = ({open, onClose, children, closeIcon}) => {
if (!open) return null;
return (
<div data-testid="modal">
{closeIcon && <button data-testid="close-icon" onClick={onClose}>x</button>}
{children}
</div>
);
};
MockModal.Header = ({children}) => <div data-testid="modal-header">{children}</div>;
MockModal.Content = ({children}) => <div data-testid="modal-content">{children}</div>;
MockModal.Actions = ({children}) => <div data-testid="modal-actions">{children}</div>;
MockModal.Description = ({children}) => <div data-testid="modal-description">{children}</div>;
return {
...jest.requireActual('./Theme'),
Modal: MockModal,
Button: ({children, onClick, disabled, color, ...props}) => (
<button onClick={onClick} disabled={disabled} data-testid={`button-${children}`} {...props}>{children}</button>
),
Header: ({children, as}) => <div data-testid="header">{children}</div>,
Divider: () => <hr data-testid="divider"/>,
Loader: ({children}) => <div data-testid="loader">{children}</div>,
Message: ({children, warning, negative, positive}) => (
<div data-testid={`message-${warning ? 'warning' : negative ? 'negative' : positive ? 'positive' : 'info'}`}>
{children}
</div>
),
Progress: ({percent, children}) => (
<div data-testid="progress" data-percent={percent}>{children}</div>
),
};
});
// Import mocked API functions
import {getBulkTagPreview, applyBulkTags, getBulkTagProgress} from '../api';
@ -60,23 +25,18 @@ const MockTagsGroup = ({tagNames, onClick}) => (
</div>
);
// Create a wrapper with TagsContext
const TagsContextWrapper = ({children}) => {
// Render with the real ui library components (Mantine-backed) plus TagsContext.
const renderWithTags = (ui, options) => {
const tagsValue = {
tagNames: ['tag1', 'tag2', 'tag3'],
TagsGroup: MockTagsGroup,
};
return (
<TagsContext.Provider value={tagsValue}>
{children}
</TagsContext.Provider>
return renderWithProviders(
<TagsContext.Provider value={tagsValue}>{ui}</TagsContext.Provider>,
options,
);
};
// Custom render function with TagsContext
const renderWithTags = (ui, options) =>
render(ui, {wrapper: TagsContextWrapper, ...options});
describe('BulkTagModal', () => {
const defaultProps = {
open: true,
@ -102,17 +62,17 @@ describe('BulkTagModal', () => {
describe('Modal Rendering', () => {
it('renders modal when open is true', async () => {
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not render modal when open is false', () => {
renderWithTags(<BulkTagModal {...defaultProps} open={false}/>);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('shows header', async () => {
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Bulk Tag Files');
expect(screen.getByText('Bulk Tag Files')).toBeInTheDocument();
});
});
@ -121,7 +81,7 @@ describe('BulkTagModal', () => {
getBulkTagPreview.mockImplementation(() => new Promise(() => {
})); // Never resolves
renderWithTags(<BulkTagModal {...defaultProps} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
});
@ -142,7 +102,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/10 files will be affected/i)).toBeInTheDocument();
});
expect(screen.queryByTestId('message-warning')).not.toBeInTheDocument();
expect(screen.queryByText(/Tagging over 50 files/i)).not.toBeInTheDocument();
});
it('Apply button is disabled when no changes', async () => {
@ -150,7 +110,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/2 files will be affected/i)).toBeInTheDocument();
});
const applyButton = screen.getByTestId('button-Apply Tags');
const applyButton = screen.getByRole('button', {name: 'Apply Tags'});
expect(applyButton).toBeDisabled();
});
});
@ -183,7 +143,7 @@ describe('BulkTagModal', () => {
await waitFor(() => {
expect(screen.getByText(/2 files will be affected/i)).toBeInTheDocument();
});
const cancelButton = screen.getByTestId('button-Cancel');
const cancelButton = screen.getByRole('button', {name: 'Cancel'});
act(() => {
cancelButton.click();
});
@ -197,7 +157,7 @@ describe('BulkTagModal', () => {
getBulkTagPreview.mockResolvedValue(undefined);
renderWithTags(<BulkTagModal {...defaultProps} />);
// Modal should still be visible
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
});

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

@ -175,9 +175,6 @@ export function isoDatetimeToString(dt, time = false) {
}
export function CardLink({to, newTab = false, ...props}) {
const {t} = useContext(ThemeContext);
props = {...props, ...t};
props = newTab === true ? {...props, target: '_blank', rel: 'noopener noreferrer'} : props;
return <Link to={to} className="no-link-underscore card-link" {...props}>
{props.children}
@ -185,13 +182,11 @@ export function CardLink({to, newTab = false, ...props}) {
}
export function ExternalCardLink({to, children, ...props}) {
const {t} = useContext(ThemeContext);
return <a
href={to}
target='_blank'
rel='noopener noreferrer'
className='no-link-underscore card-link'
{...t}
{...props}
>
{children}
@ -199,10 +194,9 @@ export function ExternalCardLink({to, children, ...props}) {
}
export function PreviewLink({file, children, className, ...props}) {
const {t} = useContext(ThemeContext);
const {setPreviewFile} = React.useContext(FilePreviewContext);
className = className ? `clickable ${className}` : `clickable `;
return <span className={className} onClick={() => setPreviewFile(file)} {...props} {...t}>
return <span className={className} onClick={() => setPreviewFile(file)} {...props}>
{children}
</span>
}

View file

@ -1,8 +1,7 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import {Link, Route, Routes, useLocation, useNavigate, useParams, useSearchParams} from "react-router";
import {Grid, Icon as SIcon, Loader, StatisticLabel, StatisticValue} from "semantic-ui-react";
import {deleteFileGroups, getDocStatistics, tagFileGroup, untagFileGroup} from "../api";
import {Media, ThemeContext} from "../contexts/contexts";
import {Media} from "../contexts/contexts";
import {
APIButton,
BackButton,
@ -18,7 +17,7 @@ import {
toLocaleString,
useTitle
} from "./Common";
import {Button, darkTheme, Header, Icon, Segment, Statistic, Tab} from "./Theme";
import {Button, ButtonGroup, Group, Header, Loading, Panel, Statistic, StatisticGroup, Tabs} from "./ui";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {DeepSearchHint, docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
@ -88,15 +87,15 @@ function DocsPage() {
<Button color='violet' disabled={_.isEmpty(selectedDocs)}
onClick={() => setBulkTagOpen(true)}>Tag</Button>
<APIButton
color='red'
role='danger'
disabled={_.isEmpty(selectedDocs)}
confirmButton='Delete'
confirmContent='Are you sure you want to delete these documents? This cannot be undone.'
onClick={() => onDelete(false)}
obeyWROLMode={true}
>Delete</APIButton>
<Button color='grey' onClick={invertSelection} disabled={_.isEmpty(docs)}>Invert</Button>
<Button color='yellow' onClick={clearSelection}
<Button role='cancel' onClick={invertSelection} disabled={_.isEmpty(docs)}>Invert</Button>
<Button role='cancel' onClick={clearSelection}
disabled={_.isEmpty(docs) || _.isEmpty(selectedDocs)}>Clear</Button>
<BulkTagModal
open={bulkTagOpen}
@ -159,7 +158,6 @@ function DocPage() {
const {fileGroupId} = useParams();
const navigate = useNavigate();
const {docFile, doc, fetchDoc} = useDoc(parseInt(fileGroupId));
const {theme} = useContext(ThemeContext);
const [searchParams] = useSearchParams();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
// Deep-link params: `loc` = EPUB spine index, `page` = PDF page, `q` = search term.
@ -173,7 +171,7 @@ function DocPage() {
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
if (docFile === null) {
return <Segment><Loader active/></Segment>;
return <Panel><Loading/></Panel>;
}
if (docFile === undefined) {
return <>
@ -282,72 +280,63 @@ function DocPage() {
return mt.split('/').pop().toUpperCase();
};
// About pane.
const aboutPane = {
menuItem: 'About', render: () => <Tab.Pane>
{doc && doc.description && <>
<Header as='h3'>Description</Header>
<p>{doc.description}</p>
</>}
// About tab.
const aboutTab = <>
{doc && doc.description && <>
<Header as='h3'>Description</Header>
<p>{doc.description}</p>
</>}
{doc && doc.publisher && <>
<Header as='h3'>Publisher</Header>
<p>{doc.publisher}</p>
</>}
{doc && doc.publisher && <>
<Header as='h3'>Publisher</Header>
<p>{doc.publisher}</p>
</>}
{doc && doc.language && <>
<Header as='h3'>Language</Header>
<p>{doc.language}</p>
</>}
{doc && doc.language && <>
<Header as='h3'>Language</Header>
<p>{doc.language}</p>
</>}
{doc && doc.subject && <>
<Header as='h3'>Subject</Header>
<p><Link to={`/docs?subject=${encodeURIComponent(doc.subject)}`}>{doc.subject}</Link></p>
</>}
{doc && doc.subject && <>
<Header as='h3'>Subject</Header>
<p><Link to={`/docs?subject=${encodeURIComponent(doc.subject)}`}>{doc.subject}</Link></p>
</>}
{doc && doc.page_count && <>
<Header as='h3'>Page Count</Header>
<p>{doc.page_count}</p>
</>}
{doc && doc.page_count && <>
<Header as='h3'>Page Count</Header>
<p>{doc.page_count}</p>
</>}
<Header as='h3'>Size</Header>
<p>{humanFileSize(size)}</p>
</Tab.Pane>
};
<Header as='h3'>Size</Header>
<p>{humanFileSize(size)}</p>
</>;
// Files pane.
// Files tab.
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const filesPane = {
menuItem: 'Files', render: () => <Tab.Pane>
<table style={tableStyle}>
<tbody>
{docFile.files && docFile.files.map((file, idx) =>
<tr key={idx}>
<td style={labelStyle}>
<strong>{file.path.split('.').pop().toUpperCase()}</strong>
</td>
<td>
<PreviewPath path={file.path} mimetype={file.mimetype} taggable={false}>
{file.path}
</PreviewPath>
{file.size && <span style={{marginLeft: '1em', color: 'grey'}}>
({humanFileSize(file.size)})
</span>}
</td>
</tr>
)}
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={directory}/></td>
</tr>
</tbody>
</table>
</Tab.Pane>
};
const tabPanes = [aboutPane, filesPane];
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
const filesTab = <table style={tableStyle}>
<tbody>
{docFile.files && docFile.files.map((file, idx) =>
<tr key={idx}>
<td style={labelStyle}>
<strong>{file.path.split('.').pop().toUpperCase()}</strong>
</td>
<td>
<PreviewPath path={file.path} mimetype={file.mimetype} taggable={false}>
{file.path}
</PreviewPath>
{file.size && <span style={{marginLeft: '1em', color: 'var(--muted)'}}>
({humanFileSize(file.size)})
</span>}
</td>
</tr>
)}
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={directory}/></td>
</tr>
</tbody>
</table>;
return <>
<BackButton/>
@ -358,56 +347,51 @@ function DocPage() {
<iframe
src={embedUrl}
title={docFile.title || docFile.name}
style={{width: '100%', height: '80vh', border: '1px solid #ccc', borderRadius: '4px'}}
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
/>
</div>}
<Segment>
<Panel>
<Header as='h2'>{docFile.title || docFile.name}</Header>
{docFile.author && <Header as='h3'>
Author: <Link to={`/docs?author=${encodeURIComponent(docFile.author)}`}>{docFile.author}</Link>
</Header>}
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column>
<Header as='h4'>Format: {formatLabel()}{doc && doc.page_count && ` (${doc.page_count} pages)`}</Header>
</Grid.Column>
{publishedDatetimeString && <Grid.Column>
<Header as='h4'>Published: {publishedDatetimeString}</Header>
</Grid.Column>}
</Grid.Row>
</Grid>
<Group gap='xl' wrap='wrap'>
<Header as='h4'>Format: {formatLabel()}{doc && doc.page_count && ` (${doc.page_count} pages)`}</Header>
{publishedDatetimeString && <Header as='h4'>Published: {publishedDatetimeString}</Header>}
</Group>
{(() => {
const formatButtons = viewableFiles.length > 1 ? <Button.Group>
const formatButtons = viewableFiles.length > 1 ? <ButtonGroup>
{viewableFiles.map((file, idx) => {
const mt = file.mimetype || '';
let btnColor;
if (mt.startsWith('application/epub')) btnColor = 'yellow';
else if (mt === 'application/pdf') btnColor = 'red';
const isSelected = idx === selectedFileIndex;
return <Button
key={file.path}
active={idx === selectedFileIndex}
onClick={() => setSelectedFileIndex(idx)}
color={idx === selectedFileIndex ? btnColor : undefined}
basic={idx !== selectedFileIndex}
color={isSelected ? btnColor : undefined}
variant={isSelected ? 'filled' : 'default'}
>
{formatLabel(file.mimetype)}
</Button>;
})}
</Button.Group> : null;
</ButtonGroup> : null;
const actionButtons = <>
{openUrl && <Button as='a' href={openUrl} target='_blank' rel='noreferrer' color='violet'>
<SIcon name='expand arrows alternate'/> Open
{openUrl && <Button component='a' href={openUrl} target='_blank' rel='noreferrer'
color='violet' icon='expand arrows alternate'>
Open
</Button>}
{downloadUrl && <Button as='a' href={downloadUrl}>
<SIcon name='download'/> Download
{downloadUrl && <Button component='a' href={downloadUrl} icon='download'>
Download
</Button>}
<APIButton
color='red'
role='danger'
confirmContent='Are you sure you want to delete this document? All files will be deleted.'
confirmButton='Delete'
onClick={() => handleDelete(false)}
@ -431,13 +415,20 @@ function DocPage() {
</Media>
</>;
})()}
</Segment>
</Panel>
<Segment>
<Panel>
<TagsSelector selectedTagNames={docFile.tags} onAdd={localAddTag} onRemove={localRemoveTag}/>
</Segment>
</Panel>
<Tab menu={tabMenu} panes={tabPanes}/>
<Tabs defaultValue='about'>
<Tabs.List>
<Tabs.Tab value='about'>About</Tabs.Tab>
<Tabs.Tab value='files'>Files</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='about' pt='md'>{aboutTab}</Tabs.Panel>
<Tabs.Panel value='files' pt='md'>{filesTab}</Tabs.Panel>
</Tabs>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
@ -479,22 +470,16 @@ function AuthorsPage() {
const searchInputRef = React.useRef();
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Author filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(authors) || authors.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
</Grid.Row>
</Grid>
<SearchInput
placeholder='Author filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(authors) || authors.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</div>;
return <>
@ -517,22 +502,16 @@ function SubjectsPage() {
const searchInputRef = React.useRef();
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Subject filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(subjects) || subjects.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
</Grid.Row>
</Grid>
<SearchInput
placeholder='Subject filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(subjects) || subjects.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</div>;
return <>
@ -568,45 +547,24 @@ function DocsStatisticsPage() {
}, []);
if (statistics === null) {
return <Loader active inline='centered'/>;
return <Loading/>;
}
if (statistics === undefined) {
return <ErrorMessage>Unable to fetch Doc Statistics</ErrorMessage>;
}
return <Segment>
<Header as='h1' textAlign='center'>Documents</Header>
<Statistic.Group>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.doc_count)}</StatisticValue>
<StatisticLabel>Documents</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.epub_count)}</StatisticValue>
<StatisticLabel>eBooks</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.pdf_count)}</StatisticValue>
<StatisticLabel>PDFs</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.other_count)}</StatisticValue>
<StatisticLabel>Other</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{humanFileSize(statistics.total_size)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.author_count)}</StatisticValue>
<StatisticLabel>Authors</StatisticLabel>
</Statistic>
<Statistic style={{margin: '2em'}}>
<StatisticValue>{toLocaleString(statistics.subject_count)}</StatisticValue>
<StatisticLabel>Subjects</StatisticLabel>
</Statistic>
</Statistic.Group>
</Segment>;
return <Panel>
<Header as='h1' style={{textAlign: 'center'}}>Documents</Header>
<StatisticGroup>
<Statistic value={toLocaleString(statistics.doc_count)} label='Documents'/>
<Statistic value={toLocaleString(statistics.epub_count)} label='eBooks'/>
<Statistic value={toLocaleString(statistics.pdf_count)} label='PDFs'/>
<Statistic value={toLocaleString(statistics.other_count)} label='Other'/>
<Statistic value={humanFileSize(statistics.total_size)} label='Total Size'/>
<Statistic value={toLocaleString(statistics.author_count)} label='Authors'/>
<Statistic value={toLocaleString(statistics.subject_count)} label='Subjects'/>
</StatisticGroup>
</Panel>;
}
export function DocsRoute() {

View file

@ -1,17 +1,20 @@
import {Button, Header, Icon, Modal, Placeholder, Progress, Segment, Table} from "./Theme";
import {
ActionInput,
Button,
Checkbox,
Form,
Grid,
IconGroup,
Input,
PlaceholderLine,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow
} from "semantic-ui-react";
Header,
Icon,
IconButton,
IconStack,
Modal,
Panel,
Placeholder,
Progress,
Skeleton,
Stack,
Table,
TextInput,
} from "./ui";
import {
APIButton,
DirectorySearch,
@ -23,6 +26,7 @@ import {
useIsIgnoredDirectory
} from "./Common";
import React, {useContext, useEffect, useState} from "react";
import {IconArrowsMove, IconChevronsUp, IconCursorText, IconTags} from "@tabler/icons-react";
import {useDropzone} from "react-dropzone";
import {useHotkeys} from "react-hotkeys-hook";
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
@ -111,23 +115,28 @@ function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loading
const isLoading = loadingFolders && loadingFolders.has(path);
const depth = (path.match(/\//g) || []).length;
const f = <TableRow
const f = <Table.Row
key={path}
disabled={isLoading || disabled}
className={dragClass}
style={(isLoading || disabled) ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox checked={showAsSelected} onChange={() => onSelect(folder['path'])}/>
</TableCell>
<TableCell onClick={() => shouldAllowClick() && onFolderClick(path)} className='file-path' colSpan={2} disabled={is_empty}>
</Table.Cell>
<Table.Cell
onClick={() => shouldAllowClick() && onFolderClick(path)}
className='file-path'
colSpan={2}
style={is_empty ? {opacity: 0.5} : undefined}
>
{depthIndentation(pathWithNoTrailingSlash)}
{is_empty ? <Icon name='folder outline'/> : <Icon name='folder'/>}
{ignored && <Icon name='eye slash' color='red'/>}
{name}
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
if (children) {
// Folder has children, recursively display them.
children = sortData(children);
@ -164,27 +173,27 @@ function File({file, onFileClick, disabled}) {
const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting;
// Visual highlight class
const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : '');
return <TableRow
return <Table.Row
key={path}
disabled={disabled}
className={dragClass}
style={disabled ? {opacity: 0.5, pointerEvents: 'none'} : undefined}
onMouseDown={(e) => handleDragStart(path, e)}
onMouseEnter={() => handleDragMove(path)}
>
<TableCell collapsing>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox checked={showAsSelected} onChange={() => onSelect(path)}/>
</TableCell>
<TableCell onClick={() => shouldAllowClick() && onFileClick(file)} className='file-path'>
</Table.Cell>
<Table.Cell onClick={() => shouldAllowClick() && onFileClick(file)} className='file-path'>
{depthIndentation(path)}
<FileRowTagIcon file={file}/>
{/*null size to make icon the correct size*/}
<FileIcon file={file} size={null}/>
{pathName(path)}
</TableCell>
<TableCell collapsing textAlign='right'>
</Table.Cell>
<Table.Cell numeric style={{width: '1%', whiteSpace: 'nowrap'}}>
{humanFileSize(size)}
</TableCell>
</TableRow>
</Table.Cell>
</Table.Row>
}
function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFolders}) {
@ -209,17 +218,15 @@ function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFold
function SkeletonFileRow({depth}) {
const indentation = '\xa0\xa0\xa0\xa0'.repeat(depth);
return (
<TableRow>
<TableCell collapsing>
<Table.Row>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<Checkbox disabled/>
</TableCell>
<TableCell className='file-path' colSpan={2}>
</Table.Cell>
<Table.Cell className='file-path' colSpan={2}>
{indentation}
<Placeholder style={{display: 'inline-block', width: '200px', marginLeft: '0.5em'}}>
<PlaceholderLine/>
</Placeholder>
</TableCell>
</TableRow>
<Skeleton height={12} width={200} radius={0} animate style={{display: 'inline-block', marginLeft: '0.5em'}}/>
</Table.Cell>
</Table.Row>
);
}
@ -387,28 +394,26 @@ export function FileBrowser() {
const [footerRef, footerWidth] = useElementWidth();
// Expand icon-only buttons to include labels once the footer is wide enough to fit them.
const showLabels = footerWidth >= FOOTER_LABELS_MIN_WIDTH;
// Semantic UI renders a labeled icon button when `content` and `labelPosition` are set, else icon-only.
const labelProps = (content) => showLabels ? {content, labelPosition: 'left'} : {};
// Show a label alongside the icon once there's room; otherwise the icon carries the meaning alone.
const label = (text) => showLabels ? text : null;
const footerClassName = `sticky-footer ${inverted}`;
const footer = <div className={footerClassName} ref={footerRef}>
<FilesRefreshButton paths={selectedPaths} showLabel={showLabels}/>
<APIButton
icon='trash'
color='red'
role='danger'
confirmContent='Are you sure you want to delete these files?'
confirmButton='Delete'
onClick={onDelete}
disabled={selectedPathsCount === 0}
obeyWROLMode={true}
{...labelProps('Delete')}
/>
<Button icon='text cursor'
>{label('Delete')}</APIButton>
<Button icon={IconCursorText}
color='yellow'
onClick={() => setRenameOpen(true)}
disabled={wrolModeEnabled || selectedPathsCount !== 1}
{...labelProps('Rename')}
/>
>{label('Rename')}</Button>
{selectedPathsCount === 1 ?
<RenameModal
open={renameOpen}
@ -417,12 +422,11 @@ export function FileBrowser() {
path={selectedPaths[0]}
onSubmit={reset}
/> : null}
<Button icon='move'
<Button icon={IconArrowsMove}
color='teal'
disabled={wrolModeEnabled || selectedPathsCount === 0}
onClick={() => setMoveOpen(true)}
{...labelProps('Move')}
/>
>{label('Move')}</Button>
{selectedPathsCount > 0 ?
<MoveModal open={moveOpen}
onClose={() => setMoveOpen(false)}
@ -435,10 +439,9 @@ export function FileBrowser() {
disabled={wrolModeEnabled}
style={{paddingLeft: '1em', paddingRight: showLabels ? '1em' : '0.8em'}}
>
<IconGroup>
<IconStack corner={<Icon name='add'/>} label='New folder'>
<Icon name='folder'/>
<Icon corner name='add'/>
</IconGroup>
</IconStack>
{showLabels ? <>&nbsp;New Folder</> : null}
</Button>
<MakeDirectoryModal
@ -452,8 +455,7 @@ export function FileBrowser() {
color='green'
disabled={wrolModeEnabled || (selectedPathsCount > 0 && !singleDirectorySelected)}
onClick={() => setUploadOpen(true)}
{...labelProps('Upload')}
/>
>{label('Upload')}</Button>
<FileBrowserUploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
@ -465,8 +467,7 @@ export function FileBrowser() {
icon={isDirectoryIgnored ? 'eye' : 'eye slash'}
disabled={!singleDirectorySelected || wrolModeEnabled}
onClick={() => setIgnoreDirectoryOpen(true)}
{...labelProps(isDirectoryIgnored ? 'Unignore' : 'Ignore')}
/>
>{label(isDirectoryIgnored ? 'Unignore' : 'Ignore')}</Button>
<IgnoreDirectoryModal
open={ignoreDirectoryOpen}
onClose={onIgnore}
@ -476,11 +477,10 @@ export function FileBrowser() {
/>
<Button
color='violet'
icon='tags'
icon={IconTags}
disabled={selectedPathsCount === 0}
onClick={() => setBulkTagOpen(true)}
{...labelProps('Tag')}
/>
>{label('Tag')}</Button>
<BulkTagModal
open={bulkTagOpen}
onClose={() => setBulkTagOpen(false)}
@ -536,10 +536,7 @@ export function FileBrowser() {
);
if (browseFiles === null) {
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>;
return <Placeholder lines={2}/>;
} else if (browseFiles === undefined) {
return <ErrorMessage>Could not fetch files</ErrorMessage>
}
@ -551,31 +548,29 @@ export function FileBrowser() {
openFolders={openFolders}
>
<div className='file-browser-filter-container'>
<Input
icon='filter'
iconPosition='left'
<ActionInput
leftSection={<Icon name='filter'/>}
placeholder='Filter files...'
value={filterStr}
onChange={(e, {value}) => setFilterStr(value)}
onChange={(e) => setFilterStr(e.target.value)}
aria-label='Filter files'
className='file-browser-filter'
ref={filterInputRef}
action={<Button
action={<IconButton
icon='close'
type='button'
onClick={() => setFilterStr('')}
disabled={!filterStr}
className='search-clear'
aria-label='Clear filter'
label='Clear filter'
/>}
/>
<Button
icon='angle double up'
<IconButton
icon={IconChevronsUp}
type='button'
onClick={onCollapseAll}
disabled={!openFolders || openFolders.length === 0}
title='Close all folders'
aria-label='Close all folders'
label='Close all folders'
style={{marginLeft: '0.5em'}}
/>
</div>
@ -598,7 +593,6 @@ function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile
return (
<div style={{marginBottom: '4em'}} className={containerClassName}>
<SortableTable
tableProps={{striped: true}}
data={browseFiles}
tableHeaders={headers}
defaultSortColumn='path'
@ -624,11 +618,8 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
const [value, setValue] = React.useState(name);
const disabled = value === name;
const handleInputChange = (e, props) => {
if (e) {
e.preventDefault();
}
setValue(props.value);
const handleInputChange = (e) => {
setValue(e.target.value);
}
const handleSubmit = async (e) => {
@ -652,25 +643,31 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
setValue(pathName(path));
}, [path]);
return <Modal closeIcon
return <Modal
open={open}
onClose={onClose}
>
<Modal.Header>Rename: {name}</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<Header as='h4'>New Name</Header>
<pre>{parent ? parent + '/' : null}{value}</pre>
<Input fluid
<TextInput
autoFocus
label='Name'
value={value}
onChange={handleInputChange}
/>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button
onClick={() => setValue(name)}
style={{marginRight: 'auto'}}
>
Reset
</Button>
<Button
color='violet'
disabled={disabled}
@ -678,12 +675,6 @@ export function RenameModal({open, onClose, path, onSubmit, onPending}) {
>
Rename
</Button>
<Button
onClick={() => setValue(name)}
floated='left'
>
Reset
</Button>
</Modal.Actions>
</Modal>
}
@ -710,47 +701,47 @@ export function MoveModal({open, paths, onClose, onSubmit}) {
}
}
const fileCell = i => <TableCell>
const fileCell = i => <Table.Cell>
<pre style={{marginTop: 0, marginBottom: 0}}>{i}</pre>
</TableCell>;
</Table.Cell>;
const destinationRows = paths.map(i => <TableRow key={i}>
const destinationRows = paths.map(i => <Table.Row key={i}>
{fileCell(i)}
{destination ? fileCell(destination + '/' + pathName(i)) : fileCell(pathName(i))}
</TableRow>);
</Table.Row>);
return <Modal closeIcon
return <Modal
onClose={onClose}
open={open}
>
<Modal.Header>Move Files/Directories</Modal.Header>
<Modal.Content>
<p>Search for a directory to move your files into:</p>
<DirectorySearch onSelect={handleDirectory} disabled={pending} input={{autoFocus: true}}/>
<DirectorySearch onSelect={handleDirectory} disabled={pending} autoFocus/>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Source</TableHeaderCell>
<TableHeaderCell>Destination</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Source</Table.HeaderCell>
<Table.HeaderCell>Destination</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{destinationRows}
</TableBody>
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button
onClick={() => setDestination(null)}
style={{marginRight: 'auto'}}
disabled={pending}
>Reset</Button>
<Button
color='violet'
onClick={handleMove}
disabled={pending}
>Move</Button>
<Button
onClick={() => setDestination(null)}
floated='left'
disabled={pending}
>Reset</Button>
</Modal.Actions>
</Modal>
}
@ -779,20 +770,20 @@ export function IgnoreDirectoryModal({open, onClose, onSubmit, directory, ignore
const content = ignored ?
'Remove ignore of the following directory? You must refresh your files after this.'
: 'Ignore the following directory? The files within will not be indexed and will not show in search results.';
let submitButton = <Button primary
let submitButton = <Button role='primary'
disabled={pending}
onClick={handleIgnore}
>{ignored ? 'Un-ignore' : 'Ignore'}</Button>;
return <Modal closeIcon open={open} onClose={onClose}>
return <Modal open={open} onClose={onClose}>
<Modal.Header>{header}</Modal.Header>
<Modal.Content>
{content}
<pre>{mediaDirectory}/{directory}</pre>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' style={{marginRight: 'auto'}} onClick={onClose}>Close</Button>
{submitButton}
<Button secondary floated='left' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
}
@ -801,11 +792,8 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
const [value, setValue] = useState('');
const mediaDirectory = useMediaDirectory();
const handleInputChange = (e, props) => {
if (e) {
e.preventDefault();
}
setValue(props.value);
const handleInputChange = (e) => {
setValue(e.target.value);
}
const handleSubmit = async (e) => {
@ -824,18 +812,18 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
}
}
return <Modal closeIcon open={open} onClose={onClose}>
return <Modal open={open} onClose={onClose}>
<Modal.Header>Make Directory</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit}>
<pre>{mediaDirectory}/{parent}{value}</pre>
<Input fluid
<TextInput
autoFocus
label='Name'
value={value}
onChange={handleInputChange}
/>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button color='violet' onClick={handleSubmit}>Create</Button>
@ -845,7 +833,6 @@ export function MakeDirectoryModal({open, onClose, parent, onSubmit}) {
export function FileBrowserUploadModal({open, onClose, destination, onComplete}) {
const mediaDirectory = useMediaDirectory();
const {t} = React.useContext(ThemeContext);
const {
setFiles,
@ -909,27 +896,22 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
color = 'orange';
statusString = 'Already Exists:';
}
let indicating = status === 'pending';
return <Grid.Row key={name}>
<Grid.Column mobile={3} tablet={2} computer={1}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='big'/>
</Grid.Column>
<Grid.Column mobile={13} tablet={14} computer={15}>
<Progress progress indicating={indicating} percent={percent} color={color}>
<p {...t}>{statusString} {name}</p>
</Progress>
</Grid.Column>
</Grid.Row>
return <div key={name} style={{display: 'flex', alignItems: 'center', gap: '0.75em'}}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='large'/>
<div style={{flex: 1}}>
<Progress percent={percent} color={color} label={`${statusString} ${name}`}/>
</div>
</div>
})
}
const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory;
return <Modal closeIcon open={open} onClose={handleClose} size='large'>
return <Modal open={open} onClose={handleClose} size='large'>
<Modal.Header>Upload to: {displayPath}</Modal.Header>
<Modal.Content>
<Form>
<form onSubmit={e => e.preventDefault()}>
{tagsSelector}
<div style={{marginTop: '1em'}}>
<Toggle
@ -939,49 +921,27 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
disabled={inProgress}
/>
</div>
</Form>
</form>
<Segment>
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Header icon>
<Icon name='file text'/>
Click here, or drop files here to upload
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
</div>
</Form>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Panel style={{marginTop: '1em', padding: '1em'}}>
<div {...getRootProps()} style={{cursor: 'pointer', textAlign: 'center'}}>
<input {...getInputProps()}/>
<Header as='h4' icon='file text'>Click here, or drop files here to upload</Header>
</div>
</Panel>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progresses && Object.keys(progresses).length > 1 &&
<div style={{marginTop: '1em'}}>
<Progress percent={overallProgress} color='blue' label='Overall Progress'/>
</div>}
<Stack gap={8} style={{marginTop: '1em'}}>
{progressBars}
</Grid>
</Stack>
</Modal.Content>
<Modal.Actions>
<Button
role='cancel'
onClick={handleClose}
disabled={inProgress}
>

View file

@ -155,7 +155,9 @@ describe('FileBrowser', () => {
// Find the upload button
const buttons = screen.getAllByRole('button');
const uploadButton = buttons.find(btn => btn.classList.contains('green'));
const uploadButton = buttons.find(btn =>
btn.classList.contains('green') || btn.querySelector('[class*="upload"]')
);
expect(uploadButton).not.toBeDisabled();
});
@ -173,7 +175,9 @@ describe('FileBrowser', () => {
// Find the upload button
const buttons = screen.getAllByRole('button');
const uploadButton = buttons.find(btn => btn.classList.contains('green'));
const uploadButton = buttons.find(btn =>
btn.classList.contains('green') || btn.querySelector('[class*="upload"]')
);
expect(uploadButton).toBeDisabled();
});

View file

@ -1,14 +1,12 @@
import React from "react";
import _ from "lodash";
import {Button as SButton, Dropdown, Header as SHeader, Icon, Image,} from "semantic-ui-react";
import {TagsSelector} from "../Tags";
import {encodeMediaPath, isSupportedArchive, useLocalStorage} from "./Common";
import {getFile, tagFileGroup, untagFileGroup} from "../api";
import {ArchivePreviewContent} from "./ArchivePreview";
import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, Modal} from "./Theme";
import {toast} from "./ui";
import {Button, ButtonGroup, Header, Icon, IconButton, Menu, Modal, toast} from "./ui";
import {useOneQuery} from "../hooks/customHooks";
import {ShareButton} from "./Share";
import {AddToPlaylistButton} from "./AddToPlaylist";
@ -63,25 +61,31 @@ function OpenInSlicerButton({previewFile}) {
// Browsers cannot report which slicers are installed, so remember the last one used.
const [slicerKey, setSlicerKey] = useLocalStorage('preferredSlicer', SLICERS[0].key);
const slicer = SLICERS.find(s => s.key === slicerKey) || SLICERS[0];
return <SButton.Group color='violet' size='small'>
<Button color='violet' as='a' href={getSlicerURL(previewFile, slicer.scheme)} icon labelPosition='left'>
<Icon name='cube'/>Open in {slicer.text}
return <ButtonGroup>
<Button color='violet' component='a' href={getSlicerURL(previewFile, slicer.scheme)} icon='cube' size='small'>
Open in {slicer.text}
</Button>
<Dropdown className='button icon' floating trigger={<></>}>
<Dropdown.Menu>
<Menu position='bottom-end' withinPortal>
<Menu.Target>
<IconButton color='violet' icon='dropdown' label='Choose a different slicer' size='small'/>
</Menu.Target>
<Menu.Dropdown>
{SLICERS.map(s =>
<Dropdown.Item key={s.key} text={`Open in ${s.text}`}
as='a' href={getSlicerURL(previewFile, s.scheme)}
onClick={(e) => {
// Navigate programmatically so keyboard activation (which may not
// follow the href) still launches the slicer.
if (e) e.preventDefault();
setSlicerKey(s.key);
window.location.assign(getSlicerURL(previewFile, s.scheme));
}}/>)}
</Dropdown.Menu>
</Dropdown>
</SButton.Group>
<Menu.Item key={s.key}
component='a' href={getSlicerURL(previewFile, s.scheme)}
onClick={(e) => {
// Navigate programmatically so keyboard activation (which may not
// follow the href) still launches the slicer.
if (e) e.preventDefault();
setSlicerKey(s.key);
window.location.assign(getSlicerURL(previewFile, s.scheme));
}}
>
Open in {s.text}
</Menu.Item>)}
</Menu.Dropdown>
</Menu>
</ButtonGroup>
}
function getEpubViewerURL(previewFile) {
@ -115,7 +119,7 @@ function getImagePreviewModal(previewFile) {
<Modal.Content>
<div className='preview-fit'>
<a href={url}>
<Image src={url} style={{maxHeight: '100%', maxWidth: '100%', objectFit: 'contain'}}/>
<img src={url} alt={name} style={{maxHeight: '100%', maxWidth: '100%', objectFit: 'contain'}}/>
</a>
</div>
</Modal.Content>
@ -131,7 +135,7 @@ function getVideoPreviewModal(previewFile) {
{name}
</Modal.Header>
<Modal.Content>
<SHeader as='h5'>{path['path']}</SHeader>
<Header as='h5'>{path['path']}</Header>
<div className='preview-fit'>
<video controls
autoPlay={true}
@ -416,14 +420,13 @@ export function FilePreviewProvider({children}) {
setErrorModalOpen(false);
}
const errorModal = <Modal closeIcon
open={errorModalOpen}
const errorModal = <Modal open={errorModalOpen}
onClose={handleErrorModalClose}
>
<Modal.Header>Unknown File</Modal.Header>
<Modal.Content>Cannot display preview, no such file exists: {previewQuery}</Modal.Content>
<Modal.Actions>
<Button onClick={handleErrorModalClose}>Close</Button>
<Button role='cancel' onClick={handleErrorModalClose}>Close</Button>
</Modal.Actions>
</Modal>
@ -500,21 +503,21 @@ export function FilePreviewProvider({children}) {
function setModalContent(content, url, downloadURL, path, taggable = true, extraButtons = null) {
// All action buttons share `size='small'` and `icon` for consistent height across viewports.
// Close is handled by the Modal's `closeIcon` (X in the top-right corner) — no dedicated button.
// Close is handled by the Modal's close button (X in the top-right corner) — no dedicated button.
const openButton = url
? <Button color='blue' as='a' href={url} size='small' icon labelPosition='left'>
<Icon name='external'/>Open
</Button>
? <Button role='primary' component='a' href={url} size='small' icon='external'>Open</Button>
: null;
const tagsDisplay = taggable ?
<TagsSelector selectedTagNames={previewFile['tags']} onAdd={localAddTag}
onRemove={localRemoveTag}/>
: null;
const downloadButton = downloadURL
? <Button color='yellow' as='a' href={downloadURL} size='small' icon='download'/>
? <IconButton color='yellow' component='a' href={downloadURL} size='small' icon='download' label='Download'/>
: null;
const directoryURL = path ? `/files?folders=${encodeURIComponent(pathDirectory(path))}` : null;
const directoryButton = path ? <Button as='a' href={directoryURL} size='small' icon='folder'/> : null;
const directoryButton = path
? <IconButton component='a' href={directoryURL} size='small' icon='folder' label='Open containing folder'/>
: null;
const pathContent = path ? <Modal.Content>
<pre>{path}</pre>
</Modal.Content>
@ -526,8 +529,7 @@ export function FilePreviewProvider({children}) {
// Single flex toolbar — same button order at every viewport size.
// `flex-wrap: wrap` lets the tags selector drop to a second line on narrow screens
// while the button groups stay together and in order: [file actions] [tags] [open/close].
setPreviewModal(<Modal closeIcon
size='fullscreen'
setPreviewModal(<Modal size='fullscreen'
open={true}
onClose={e => handleClose(e)}
>

View file

@ -17,7 +17,8 @@ import {
TagIcon,
Toggle,
textEllipsis,
useTitle
mimetypeColor,
useTitle,
} from "./Common";
import {
usePages,
@ -116,6 +117,7 @@ function EbookCard({file, sortField}) {
return <Card media={<CardPoster file={file} to={detailUrl}/>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
@ -134,6 +136,7 @@ function ImageCard({file}) {
return <Card media={<PreviewLink file={file}><CardPoster file={file}/></PreviewLink>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}
/>
}
@ -175,7 +178,8 @@ function FileCard({file}) {
</>
}
</>;
return <Card media={<CardPoster file={file}/>} title={header} meta={meta}/>
return <Card media={<CardPoster file={file}/>} title={header} meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}/>
}
export function FileCards({files}) {

View file

@ -1,27 +1,30 @@
import React, {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {Route, Routes} from "react-router";
import {Checkbox, Label, Message, Select, Button as SButton, Icon as SIcon} from "semantic-ui-react";
import {ESPLoader, Transport} from "esptool-js";
import {useDropzone} from "react-dropzone";
import _ from "lodash";
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
import {
Button,
darkTheme,
Checkbox,
Divider,
Form,
Group,
Header,
Icon,
List,
Loader,
IconButton,
Label,
Loading,
Message,
Modal,
Panel,
Progress,
Segment,
Tab,
Select,
Stack,
Table,
TextArea
} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
Tabs,
Textarea,
TextInput,
} from "./ui";
import {deleteFlasherConfig, flasherSearch, getFlasherConfigs, saveFlasherConfig} from "../api";
// The suffix used to find flashable ESP32 firmware in the media directory.
@ -50,6 +53,10 @@ const KNOWN_ESP_CHIPS = [
// colors when they render at the same width. The first eight cover today's largest same-width group (the eight
// 8-character ESP32-* names); the extras let a same-width group grow past eight before colors would repeat.
// (Truly colorblind-distinct qualitative colors top out around ten, so beyond that distinctness is best-effort.)
//
// These are deliberately NOT theme tokens: this is a fixed categorical palette used to visually tell many ESP
// chip families apart, the same way a chart's series colors stay fixed regardless of theme. See the report to
// the user for the resulting night-mode tension (non-red hues on these chips, unlike the rest of the UI).
const CHIP_COLORS = [
'#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377', '#bbbbbb', '#000000',
'#ee7733', '#332288', '#999933', '#882255',
@ -115,10 +122,10 @@ export function espImageChipId(header) {
// bundled into the frontend build, so this works fully offline once the page has loaded.
const BAUD_OPTIONS = [
{key: '115200', value: 115200, text: '115200 (safe)'},
{key: '230400', value: 230400, text: '230400'},
{key: '460800', value: 460800, text: '460800'},
{key: '921600', value: 921600, text: '921600 (fast)'},
{value: 115200, text: '115200 (safe)'},
{value: 230400, text: '230400'},
{value: 460800, text: '460800'},
{value: 921600, text: '921600 (fast)'},
];
// Web Serial is only available in a secure context (HTTPS or localhost) on Chromium-based browsers.
@ -218,10 +225,10 @@ export function withTimeout(promise, ms, message) {
// How long to wait for a device to connect before giving up (esptool-js retries can otherwise stall the UI).
const CONNECT_TIMEOUT_MS = 30_000;
// A read-only log TextArea that auto-scrolls to the newest output — but only when the user is already at the
// A read-only log Textarea that auto-scrolls to the newest output — but only when the user is already at the
// bottom, so scrolling up to read earlier lines isn't yanked back down. Starts at the bottom on mount (e.g. when
// a modal opens).
function AutoScrollLog({value, ...props}) {
function AutoScrollLog({value, style, ...props}) {
const containerRef = useRef(null);
const atBottomRef = useRef(true);
@ -241,13 +248,18 @@ function AutoScrollLog({value, ...props}) {
}, [value]);
return <div ref={containerRef}>
<TextArea readOnly value={value} onScroll={handleScroll} {...props}/>
<Textarea readOnly value={value} onScroll={handleScroll} styles={{input: style}} {...props}/>
</div>;
}
// Tab keys for the firmware-source picker, in display order: saved sets first, then the media picker, then a
// local file.
const TAB_SAVED = 'saved';
const TAB_WROLPI = 'wrolpi';
const TAB_COMPUTER = 'computer';
export function FlasherPage() {
useTitle('Flasher');
const {t, theme} = useContext(ThemeContext);
// Each entry is {name, size, address, file?: File, mediaPath?: string}. A local file has `file`; a media
// firmware has `mediaPath`. Default offset 0x0 suits a single merged firmware image.
@ -283,7 +295,7 @@ export function FlasherPage() {
const [flashError, setFlashError] = useState('');
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
const [activeTab, setActiveTab] = useState(0);
const [activeTab, setActiveTab] = useState(TAB_SAVED);
// Saved firmware configurations (flasher.yaml).
const [savedConfigs, setSavedConfigs] = useState([]);
@ -331,15 +343,14 @@ export function FlasherPage() {
}
};
// The "Add from computer" tab (index 2 in firmwarePanes) hosts the dropzone; a drop anywhere on the page or a
// click on the zone lands here.
const COMPUTER_TAB_INDEX = 2;
// The "Add from computer" tab hosts the dropzone; a drop anywhere on the page or a click on the zone lands
// here.
const onDrop = React.useCallback((droppedFiles) => {
// Only take .bin firmware; anything else dropped is ignored.
const bins = (droppedFiles || []).filter(file => file.name.toLowerCase().endsWith(FIRMWARE_SUFFIX));
if (bins.length > 0) {
addFiles(bins);
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// The dropzone root wraps the whole page so firmware can be dropped anywhere. noClick/noKeyboard because the
@ -354,7 +365,7 @@ export function FlasherPage() {
// While the user drags a file over the page, jump to the "Add from computer" tab so the drop target is shown.
useEffect(() => {
if (isDragActive) {
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, [isDragActive]);
@ -644,15 +655,11 @@ export function FlasherPage() {
if (!webSerialSupported()) {
return <PageContainer>
<Header as='h1'>Flasher</Header>
<Message icon warning>
<SIcon name='warning sign'/>
<Message.Content>
<Message.Header>Web Serial is not available</Message.Header>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message.Content>
<Message kind='warning' icon='warning sign' title='Web Serial is not available'>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message>
</PageContainer>;
}
@ -664,409 +671,414 @@ export function FlasherPage() {
const totalFlashBytes = files.reduce((sum, item) => sum + (item.size || 0), 0);
const flashEtaText = humanDuration(estimateFlashSeconds(totalFlashBytes, baudrate));
// The nested tab menu isn't inverted automatically; give it the inverted style in dark mode so it matches.
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
// A connect failure needs a download-mode reminder shown right where the user acted — both in the Connect
// segment and next to the "Filter files by detecting device" button so it isn't missed off-screen.
// panel and next to the "Filter files by detecting device" button so it isn't missed off-screen.
const connectErrorMessage = () => (error && bootHint) &&
<Message error onDismiss={() => {
setError('');
setBootHint(false);
}} style={{marginTop: '1em'}}>
<Message.Header>Could not connect to the device</Message.Header>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>;
<div style={{marginTop: '1em'}}>
<Message
kind='error'
title='Could not connect to the device'
onDismiss={() => {
setError('');
setBootHint(false);
}}
>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>
</div>;
// The firmware source is chosen via three tabs (see the ordered array below).
const computerPane = {
menuItem: 'Add from computer',
render: () => <Tab.Pane>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Segment
placeholder
onClick={busy ? undefined : open}
// The firmware source is chosen via three tabs, in this order: saved sets, the media picker, then a local
// file.
const computerPaneContent = <>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Panel
onClick={busy ? undefined : open}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {borderColor: 'var(--blue)', borderWidth: 2} : {}),
}}
>
<Header as='h4' icon='microchip'>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Panel>
<p style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</>;
const wrolpiPaneContent = <>
<p style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon='usb'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect panel. */}
{connectErrorMessage()}
{deviceChip &&
<Message kind='info' icon='microchip'>
Showing firmware for <b>{deviceChip}</b>.
<Button role='cancel' size='xs' onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</Button>
</Message>}
<TextInput
style={{marginTop: '1em'}}
leftSection={<Icon name='search'/>}
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e) => handleMediaFilterChange(e.currentTarget.value)}
/>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loading/>
: (mediaResults.length === 0
? <p style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
? <span className='wrolpi-label' style={{
'--label-color': chipColor(result.esp_chip),
color: chipTextColor(chipColor(result.esp_chip)),
}}>{result.esp_chip}</span>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='plus' label='Add to selected firmware' role='primary'
size='xs' disabled={busy}
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</>;
const savedPaneContent = <>
<p style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <Panel style={{padding: 0}}>
{savedConfigs.map((configuration, index) => <div
key={configuration.name}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {outline: '2px dashed #2185d0', outlineOffset: '-2px'} : {}),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '10px 12px',
borderBottom: index === savedConfigs.length - 1 ? undefined : '1px solid var(--border)',
}}
>
<Header icon {...t}>
<Icon name='microchip'/>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Segment>
<p {...t} style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</Tab.Pane>,
};
const wrolpiPane = {
menuItem: 'Choose from your WROLPi',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon labelPosition='left'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
<Icon name='usb'/>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect segment. */}
{connectErrorMessage()}
{deviceChip &&
<Message info>
{/* Plain (non-inverted) icon: Message backgrounds are light, so a themed white icon
vanishes. */}
<SIcon name='microchip'/>
Showing firmware for <b>{deviceChip}</b>.
<SButton size='tiny' compact onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</SButton>
</Message>}
<Form style={{marginTop: '1em'}}>
<Form.Input
fluid
icon='search'
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e, {value}) => handleMediaFilterChange(value)}
/>
</Form>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loader active inline='centered'/>
: (mediaResults.length === 0
? <p {...t} style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table compact selectable unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell {...t} style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
? <Label size='tiny' style={{
backgroundColor: chipColor(result.esp_chip),
color: chipTextColor(chipColor(result.esp_chip)),
borderColor: 'transparent',
}}>{result.esp_chip}</Label>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='plus' size='mini' primary disabled={busy}
title='Add to selected firmware'
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</Tab.Pane>,
};
const savedPane = {
menuItem: 'Saved Firmwares',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <List divided relaxed selection>
{savedConfigs.map(configuration => <List.Item key={configuration.name}>
<List.Content floated='right'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</List.Content>
<List.Icon name='save' verticalAlign='middle'/>
<List.Content onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={busy ? {} : {cursor: 'pointer'}}>
<List.Header>{configuration.name}</List.Header>
<List.Description {...t}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</List.Description>
</List.Content>
</List.Item>)}
</List>
: <p {...t} style={{opacity: 0.7}}>No saved configurations yet.</p>}
</Tab.Pane>,
};
// Tab order: saved sets first, then the media picker, then a local file.
const firmwarePanes = [savedPane, wrolpiPane, computerPane];
<div
onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={{
display: 'flex', alignItems: 'center', gap: 10, flex: 1,
cursor: busy ? 'default' : 'pointer',
}}
>
<Icon name='save'/>
<div>
<div style={{fontWeight: 600}}>{configuration.name}</div>
<div style={{fontSize: 12, opacity: 0.8}}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</div>
</div>
</div>
<IconButton icon='trash' label={`Delete ${configuration.name}`} role='danger' size='xs'
disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</div>)}
</Panel>
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
</>;
return <div {...getRootProps()}>
{/* The dropzone root wraps the whole page: firmware .bin files can be dropped anywhere and are routed to
the "Add from computer" tab. noClick is set, so this wrapper never opens the file dialog itself. */}
<input {...getInputProps()}/>
<PageContainer>
<Header as='h1'>Flasher</Header>
<p {...t}>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or more
firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media directory &mdash;
connect your device, then flash. The flash happens entirely in your browser and is written straight to
the device over USB.
</p>
<Header as='h1'>Flasher</Header>
<p>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or
more firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media
directory &mdash; connect your device, then flash. The flash happens entirely in your browser and
is written straight to the device over USB.
</p>
{/* Connection errors render inside the Connect segment (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message error onDismiss={() => setError('')}>
<Message.Header>Error</Message.Header>
<p>{error}</p>
</Message>}
{/* Connection errors render inside the Connect panel (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message kind='error' title='Error' onDismiss={() => setError('')}>
<p>{error}</p>
</Message>}
<Segment>
<Header as='h3'>1. Firmware files</Header>
<Tab
menu={tabMenu}
panes={firmwarePanes}
activeIndex={activeTab}
onTabChange={(e, {activeIndex}) => setActiveTab(activeIndex)}
/>
<Panel>
<Header as='h3'>1. Firmware files</Header>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value={TAB_SAVED}>Saved Firmwares</Tabs.Tab>
<Tabs.Tab value={TAB_WROLPI}>Choose from your WROLPi</Tabs.Tab>
<Tabs.Tab value={TAB_COMPUTER}>Add from computer</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={TAB_SAVED} pt='md'>{savedPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_WROLPI} pt='md'>{wrolpiPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_COMPUTER} pt='md'>{computerPaneContent}</Tabs.Panel>
</Tabs>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which tab
(computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table compact unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell width={4}>Flash offset</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`} warning={chipMismatch}>
<Table.Cell>
<Icon name={item.mediaPath ? 'database' : 'file'}/>
{item.name} <span style={{opacity: 0.6}}>({humanFileSize(item.size)})</span>
{chipMismatch &&
<Label color='red' size='tiny' style={{marginLeft: '0.5em'}}>
<SIcon name='warning sign'/>
Built for {chipIdName(fileChipId)}, not {chipIdName(deviceChipId)}
</Label>}
</Table.Cell>
<Table.Cell>
<Form.Input
fluid
size='small'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e, {value}) => handleAddressChange(index, value)}
/>
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved from
any source tab. */}
<Button icon labelPosition='left' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
<Icon name='save'/>
Save current firmware
</Button>
</>}
</Segment>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which
tab (computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell style={{width: '30%'}}>Flash offset</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the
// device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`}>
<Table.Cell>
<Icon name={item.mediaPath ? 'disk' : 'file'}/>
{' '}{item.name} <span style={{opacity: 0.6}}>
({humanFileSize(item.size)})
</span>
{chipMismatch &&
<span style={{marginLeft: '0.5em'}}>
<Label color='red' icon='warning sign'>
Built for {chipIdName(fileChipId)}, not{' '}
{chipIdName(deviceChipId)}
</Label>
</span>}
</Table.Cell>
<Table.Cell>
<TextInput
size='xs'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e) => handleAddressChange(index, e.currentTarget.value)}
/>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='trash' label={`Remove ${item.name}`} role='danger'
size='xs' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved
from any source tab. */}
<Button icon='save' role='save' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
Save current firmware
</Button>
</>}
</Panel>
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='tiny'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSaveConfig}>
<Form.Input
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='sm'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<TextInput
autoFocus
label='Name'
placeholder='e.g. T-Deck Meshtastic UI'
value={saveName}
onChange={(e, {value}) => setSaveName(value)}
onChange={(e) => setSaveName(e.currentTarget.value)}
/>
<p {...t} style={{opacity: 0.8}}>
<p style={{opacity: 0.8, marginTop: '0.75em'}}>
Saving {saveableFiles.length} firmware file(s) from your WROLPi. An existing configuration
with the same name will be replaced.
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<SButton onClick={() => setSaveModalOpen(false)}>Cancel</SButton>
<Button primary disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setSaveModalOpen(false)}>Cancel</Button>
<Button role='save' disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
<Segment>
<Header as='h3'>2. Connect</Header>
<Form>
<Form.Field>
<label>Baud rate</label>
<Select
options={BAUD_OPTIONS}
value={baudrate}
disabled={connected || busy}
onChange={(e, {value}) => setBaudrate(value)}
/>
</Form.Field>
{connected &&
<Message positive>
<SIcon name='usb'/> Connected to <b>{chip}</b>
</Message>}
{connected
? <Button color='grey' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button primary loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon labelPosition='left' disabled={!log} onClick={() => setLogOpen(true)}>
<Icon name='terminal'/> Logs
</Button>
</Form>
{/* Rendered outside <Form> so Semantic's ".ui.form .error.message { display:none }" rule doesn't
hide it, and here (not at the top) so it is visible right where the user clicked Connect. */}
{connectErrorMessage()}
</Segment>
<Panel>
<Header as='h3'>2. Connect</Header>
<Stack gap={12} style={{maxWidth: 320}}>
<div>
<div style={{marginBottom: 4, fontSize: 13, fontWeight: 500}}>Baud rate</div>
<Select
data={BAUD_OPTIONS.map(o => ({value: String(o.value), label: o.text}))}
value={String(baudrate)}
disabled={connected || busy}
allowDeselect={false}
onChange={(value) => value && setBaudrate(Number(value))}
/>
</div>
{connected &&
<Message kind='success' icon='usb'>Connected to <b>{chip}</b></Message>}
<Group>
{connected
? <Button role='cancel' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button role='primary' loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon='terminal' disabled={!log} onClick={() => setLogOpen(true)}>
Logs
</Button>
</Group>
</Stack>
{/* Rendered outside the fields above, and here (not at the top) so it is visible right where
the user clicked Connect. */}
{connectErrorMessage()}
</Panel>
<Segment>
<Header as='h3'>3. Flash</Header>
<Form>
<Form.Field>
<Panel danger>
<Header as='h3'>3. Flash</Header>
<Stack gap={12}>
<Checkbox
label='Erase all flash before writing'
checked={eraseAll}
disabled={busy}
onChange={(e, {checked}) => setEraseAll(checked)}
onChange={(e) => setEraseAll(e.currentTarget.checked)}
/>
</Form.Field>
<Button
color='red'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
<Icon name='lightning'/> Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span {...t} style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
{files.length > 0 && !offsetsValid &&
<p {...t} style={{marginTop: '0.5em', opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Form>
</Segment>
<div>
<Button
role='danger'
icon='lightning'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
</div>
{files.length > 0 && !offsetsValid &&
<p style={{opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Stack>
</Panel>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnDimmerClick={!flashing}
size='fullscreen'
>
<Modal.Header>{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}</Modal.Header>
<Modal.Content scrolling>
{progress !== null &&
<Progress percent={progress.percent} progress indicating={flashing}
error={!!flashError} autoSuccess={!flashError}>
{flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
</Progress>}
{flashing && flashEtaText &&
<p {...t} style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this tab
open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen one. */}
{flashError &&
<Message error>
<SIcon name='warning circle'/>
<Message.Content>
<Message.Header>Flashing failed</Message.Header>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnClickOutside={!flashing}
closeOnEscape={!flashing}
size='fullscreen'
>
<Modal.Header>
{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}
</Modal.Header>
<Modal.Content>
{progress !== null &&
<Progress
percent={progress.percent}
color={flashError ? 'red' : (flashing ? 'blue' : 'green')}
label={flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
/>}
{flashing && flashEtaText &&
<p style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this
tab open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen
one. */}
{flashError &&
<Message kind='error' icon='warning circle' title='Flashing failed'>
<p>{flashError}</p>
</Message.Content>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message success>
<SIcon name='check circle'/>
<Message.Content>
<Message.Header>Flashing complete</Message.Header>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message kind='success' icon='check circle' title='Flashing complete'>
<p>Unplug the device and power it back on to run the new firmware.</p>
</Message.Content>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content scrolling>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
</PageContainer>
</div>;
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor} from '../test-utils';
// esptool-js ships untranspiled ESM which Jest will not transform; mock it so importing Flasher.js is safe.
// Real classes (not jest.fn mock impls) so `new ESPLoader()` reliably yields an instance with main()/after().

View file

@ -1,7 +1,8 @@
import React from "react";
import {Link, NavLink} from "react-router";
import {Dropdown, Icon as SIcon, Menu} from "semantic-ui-react";
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
import {IconMenu2, IconPlugOff, IconTemperature, IconTemperaturePlus} from "@tabler/icons-react";
import {Icon, IconButton, Menu, Tooltip} from "./ui";
import {Media, SettingsContext, StatusContext} from "../contexts/contexts";
import {DarkModeToggle, HotspotStatusIcon, useLocalStorage} from "./Common";
import {ShareButton} from "./Share";
import {
@ -16,7 +17,6 @@ import {
} from "../hooks/customHooks";
import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
import {SearchIconButton} from "./Search";
import {Icon, Popup} from "./Theme";
import {HELP_VIEWER_URI, NAME, semanticUIColorMap} from "./Vars";
import {useOverflowNav} from "../hooks/useOverflowNav";
import _ from "lodash";
@ -44,6 +44,10 @@ function updateFavicon(colorName) {
if (themeColorMeta) themeColorMeta.content = hexColor;
}
// A theme token name, or null to inherit the surrounding text/icon color (never
// literally "var(--null)").
const colorVar = (color) => color ? `var(--${color})` : undefined;
const help = {to: HELP_VIEWER_URI, text: 'Help', key: 'help', target: '_blank'};
const admin = {to: '/admin', text: 'Admin', key: 'admin'};
const rightLinks = [help, admin];
@ -61,30 +65,68 @@ const allLinks = [
{to: '/more/statistics', text: 'Statistics', key: 'statistics', end: true},
];
function DropdownLinks({link}) {
return <Dropdown item text={link.text} direction='left'>
<Dropdown.Menu>
{link.links.map(l => <MenuLink key={l.to} link={l}/>)}
</Dropdown.Menu>
</Dropdown>
}
function MenuLink({link}) {
// Wrapper around NavLink to handle Navlink/Dropdown change.
// A top-level tab rendered directly in the bar (not inside a dropdown).
if (link.links) {
return <DropdownLinks link={link}/>
} else {
const end = link.end ? {end: true} : {end: undefined};
const target = link.target ? {target: link.target} : {};
return <NavLink
className='item'
to={link.to}
{...end}
{...target}
>
{link.text}
</NavLink>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <NavLink
className='wrolpi-navbar-link'
to={link.to}
{...end}
{...target}
>
{link.text}
</NavLink>
}
function DropdownMenuItem({link}) {
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
// "More" overflow menu.
if (link.links) {
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
// indented group stands in for Semantic's nested Dropdown. No current data
// uses this path (no allLinks entry has its own `links`).
return <React.Fragment>
<Menu.Label>{link.text}</Menu.Label>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</React.Fragment>
}
const end = link.end ? {end: true} : {};
const target = link.target ? {target: link.target, rel: 'noopener noreferrer'} : {};
return <Menu.Item component={NavLink} to={link.to} {...end} {...target}>
{link.text}
</Menu.Item>
}
function DropdownLinks({link}) {
// A labelled dropdown trigger: the desktop "More" overflow, or any nested
// link group handed to MenuLink.
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<button type='button' className='wrolpi-navbar-link wrolpi-navbar-link-button'>
{link.text}
<Icon name='dropdown' size='small' style={{marginLeft: '0.35em'}}/>
</button>
</Menu.Target>
<Menu.Dropdown>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
</Menu.Dropdown>
</Menu>
}
function MobileMenu({links}) {
// The mobile hamburger menu: an icon-only trigger holding every link.
return <Menu position='bottom-end' withinPortal>
<Menu.Target>
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
</Menu.Target>
<Menu.Dropdown>
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
</Menu.Dropdown>
</Menu>
}
function NavIconWrapper({children}) {
@ -123,10 +165,9 @@ export function NavBar() {
const navColor = useNavColorSetting();
const wrolpiIcon = <img src='/icon.svg' height='32px' width='32px' alt='WROLPi Home Icon'/>;
const name = <i>{NAME || wrolpiIcon}</i>;
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <SIcon name='lock'/></> : name;
const {i} = React.useContext(ThemeContext);
const topNavText = wrolModeEnabled ? <>{name}&nbsp; <Icon name='lock'/></> : name;
const homeLink = <NavLink className='item' to='/' style={{paddingTop: 0, paddingBottom: 0}}>
const homeLink = <NavLink className='wrolpi-navbar-link' to='/' style={{paddingTop: 0, paddingBottom: 0}}>
{topNavText}
</NavLink>;
@ -143,9 +184,10 @@ export function NavBar() {
// System load is high, display a warning icon.
const color = highLoad ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='tachometer alternate' size='large' color={color}/>
<Icon name='tachometer alternate' size='large' style={{color: colorVar(color)}}
label='System load warning'/>
</Link>;
systemLoadIcon = <Popup content={`Load: ${minute_1}`} trigger={icon}/>
systemLoadIcon = <Tooltip label={`Load: ${minute_1}`}>{icon}</Tooltip>
}
// RAM consumption.
@ -153,10 +195,16 @@ export function NavBar() {
let memoryIcon;
if (memoryPercent > 80) {
const color = memoryPercent > 90 ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status' color={color}>
<Icon name='microchip' size='large'/>
{/*
The colour goes on the Icon, as it does for every other indicator. It used to
be passed to `Link`, which ignores it, so the memory warning never actually
changed colour -- 81% and 95% looked identical.
*/}
const icon = <Link to='/admin/status'>
<Icon name='microchip' size='large' label='Memory usage warning'
style={{color: colorVar(color)}}/>
</Link>;
memoryIcon = <Popup content={`System Memory: ${memoryPercent.toFixed()}%`} trigger={icon}/>
memoryIcon = <Tooltip label={`System Memory: ${memoryPercent.toFixed()}%`}>{icon}</Tooltip>
}
// Any disk is busy and processes are waiting.
@ -166,9 +214,9 @@ export function NavBar() {
// Processes are waiting on disk, display a warning icon.
const color = percentIOWait > 75 ? highWarningColor : lowWarningColor;
const icon = <Link to='/admin/status'>
<Icon name='disk' size='large' color={color}/>
<Icon name='disk' size='large' style={{color: colorVar(color)}} label='Disk I/O wait warning'/>
</Link>;
diskWaitIcon = <Popup content={`Processes waiting on disk: ${percentIOWait.toFixed()}%`} trigger={icon}/>
diskWaitIcon = <Tooltip label={`Processes waiting on disk: ${percentIOWait.toFixed()}%`}>{icon}</Tooltip>
}
// CPU temperature.
@ -177,10 +225,11 @@ export function NavBar() {
if (temperature && temperature >= highTemperature) {
// CPU temperature is high, display a warning icon.
const color = temperature >= criticalTemperature ? highWarningColor : lowWarningColor;
const name = temperature >= criticalTemperature ? 'thermometer' : 'thermometer half';
const icon = <Icon data-testid='cpuTemperatureIcon' name={name} size='large' color={color}/>
const temperatureComponent = temperature >= criticalTemperature ? IconTemperaturePlus : IconTemperature;
const icon = <Icon data-testid='cpuTemperatureIcon' component={temperatureComponent} size='large'
style={{color: colorVar(color)}} label='CPU temperature warning'/>
const link = <Link to='/admin/status'>{icon}</Link>;
temperatureIcon = <Popup content={`CPU: ${temperature.toFixed()}°C`} trigger={link}/>;
temperatureIcon = <Tooltip label={`CPU: ${temperature.toFixed()}°C`}>{link}</Tooltip>;
}
// Hard-drive temperature. An overheating drive risks data loss.
@ -193,9 +242,10 @@ export function NavBar() {
let driveTemperatureIcon;
if (driveTemperature && driveTemperature >= driveHighTemperature) {
const color = driveTemperature >= driveCriticalTemperature ? highWarningColor : lowWarningColor;
const icon = <Icon data-testid='driveTemperatureIcon' name='hdd' size='large' color={color}/>
const icon = <Icon data-testid='driveTemperatureIcon' name='hdd' size='large'
style={{color: colorVar(color)}} label='Drive temperature warning'/>
const link = <Link to='/admin/controller'>{icon}</Link>;
driveTemperatureIcon = <Popup content={`${hotDrive}: ${driveTemperature.toFixed()}°C`} trigger={link}/>;
driveTemperatureIcon = <Tooltip label={`${hotDrive}: ${driveTemperature.toFixed()}°C`}>{link}</Tooltip>;
}
// A drive's SMART health is degraded. FAIL is an imminent failure (back
@ -206,31 +256,36 @@ export function NavBar() {
const {failingDevices, failing: driveFailing, warningDevices, warning: driveWarning} = useDriveHealth();
let driveHealthIcon;
if (driveFailing) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large' color={highWarningColor}/>;
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(highWarningColor)}} label='Drive health failing'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = failingDevices.length === 1
? `Drive ${failingDevices[0]} is failing its SMART health check! Back up your data.`
: `Drives failing SMART health check: ${failingDevices.join(', ')}. Back up your data.`;
driveHealthIcon = <Popup content={message} trigger={link}/>;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
} else if (driveWarning) {
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large' color={lowWarningColor}/>;
const icon = <Icon data-testid='driveHealthIcon' name='warning sign' size='large'
style={{color: colorVar(lowWarningColor)}} label='Drive health warning'/>;
const link = <Link to='/admin/controller'>{icon}</Link>;
const message = warningDevices.length === 1
? `Drive ${warningDevices[0]} has unreadable or pending sectors — check its SMART health.`
: `Drives with unreadable or pending sectors: ${warningDevices.join(', ')}. Check their SMART health.`;
driveHealthIcon = <Popup content={message} trigger={link}/>;
driveHealthIcon = <Tooltip label={message}>{link}</Tooltip>;
}
// Power issues, this is always displayed if detected.
const {underVoltage, overCurrent} = usePowerStats();
let powerIcon;
if (underVoltage || overCurrent) {
const name = underVoltage ? 'power cord' : 'lightning';
const icon = <Icon name={name} size='large' color={highWarningColor}/>;
const icon = underVoltage
? <Icon component={IconPlugOff} size='large' style={{color: colorVar(highWarningColor)}}
label='Under-voltage warning'/>
: <Icon name='lightning' size='large' style={{color: colorVar(highWarningColor)}}
label='Over-current warning'/>;
const message = underVoltage
? 'Under-voltage detected! Your power supply is insufficient!'
: 'Over-current detected! Your peripherals are using too much power!';
powerIcon = <Popup content={message} trigger={icon}/>;
powerIcon = <Tooltip label={message}><span>{icon}</span></Tooltip>;
}
// Display the temperature icon first because it can cause the system to throttle. The rest are in order of effects
@ -269,15 +324,14 @@ export function NavBar() {
}
const processingIcon = processingLink &&
<Link to={processingLink}>
<Icon loading name='circle notch' size='large'/>
<Icon loading name='circle notch' size='large' label='File worker busy'/>
</Link>;
let apiDownIcon;
if (window.apiDown) {
apiDownIcon = <Popup
content='API is not responding'
trigger={<Icon name='plug' color={highWarningColor}/>}
/>
apiDownIcon = <Tooltip label='API is not responding'>
<span><Icon name='plug' style={{color: colorVar(highWarningColor)}} label='API is not responding'/></span>
</Tooltip>
}
// Upgrade available notification - only show on native (non-Docker) installs and when WROL Mode is disabled
@ -286,12 +340,12 @@ export function NavBar() {
const commitsBehind = status.commits_behind || 0;
const branch = status.git_branch || 'unknown';
const icon = <Link to='/admin/settings#upgrade'>
<Icon name='arrow alternate circle up' size='large' color='green'/>
<Icon name='arrow alternate circle up' size='large' style={{color: 'var(--green)'}}
label='Upgrade available'/>
</Link>;
upgradeIcon = <Popup
content={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}
trigger={icon}
/>;
upgradeIcon = <Tooltip label={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}>
{icon}
</Tooltip>;
}
const icons = <React.Fragment>
@ -308,27 +362,23 @@ export function NavBar() {
return <>
<Media between={['mobile', 'tablet']}>
<Menu {...i} attached='top' color={navColor} id='global_navbar'>
<nav className='wrolpi-navbar' id='global_navbar'
style={{background: `var(--${navColor})`, color: 'var(--btn-text)'}}>
{homeLink}
<Menu.Menu position='right'>
<div className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
<Dropdown item icon="bars">
<Dropdown.Menu>
{allLinks.map(i => <MenuLink link={i} key={i.key}/>)}
{rightLinks.map(i => <MenuLink link={i} key={i.key}/>)}
</Dropdown.Menu>
</Dropdown>
</Menu.Menu>
</Menu>
<MobileMenu links={[...allLinks, ...rightLinks]}/>
</div>
</nav>
</Media>
<Media greaterThanOrEqual='tablet'>
<DesktopNav i={i} navColor={navColor} homeLink={homeLink} icons={icons}/>
<DesktopNav navColor={navColor} homeLink={homeLink} icons={icons}/>
</Media>
</>
}
function DesktopNav({i, navColor, homeLink, icons}) {
function DesktopNav({navColor, homeLink, icons}) {
const containerRef = React.useRef(null);
const homeRef = React.useRef(null);
const rightMenuRef = React.useRef(null);
@ -343,7 +393,8 @@ function DesktopNav({i, navColor, homeLink, icons}) {
return (
<div ref={containerRef}>
<Menu {...i} attached='top' color={navColor} id='global_navbar'>
<nav className='wrolpi-navbar' id='global_navbar'
style={{background: `var(--${navColor})`, color: 'var(--btn-text)'}}>
<span ref={homeRef} style={{display: 'contents'}}>{homeLink}</span>
{(isReady ? visibleLinks : allLinks).map((link, idx) => (
<span
@ -355,16 +406,16 @@ function DesktopNav({i, navColor, homeLink, icons}) {
</span>
))}
{!isReady && <span ref={moreRef} style={{visibility: 'hidden', flexShrink: 0}}>
<Dropdown item text='More'><Dropdown.Menu/></Dropdown>
<button type='button' className='wrolpi-navbar-link wrolpi-navbar-link-button'>More</button>
</span>}
{isReady && overflowLinks.length > 0 &&
<DropdownLinks link={{text: 'More', key: 'more', links: overflowLinks}}/>}
<div ref={rightMenuRef} className='right menu'>
<div ref={rightMenuRef} className='wrolpi-navbar-right'>
{icons}
<SearchIconButton/>
{rightLinks.map(i => <MenuLink link={i} key={i.key}/>)}
</div>
</Menu>
</nav>
</div>
);
}

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>
</>;
}

View file

@ -1,109 +0,0 @@
import React from 'react';
import {Button, Modal, Progress} from './Theme';
import {renderInDarkMode, renderInLightMode} from '../test-utils';
// All semantic-ui colors.
const allColors = ['red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey'];
// Light bar colors that always need dark percent text (light bars in any theme).
const lightBarColors = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const darkModeLightBarColors = ['grey', 'blue'];
describe('Button ref forwarding', () => {
// Regression: SButton is a class component, so forwarding a ref straight to it resolves to the class instance
// (no `.contains`). Semantic UI's Modal/Popup Portal calls `node.contains(...)` on the trigger's ref during
// handleDocumentClick, throwing "node.contains is not a function". The forwarded ref must be the DOM <button>.
it('resolves a forwarded ref to the underlying DOM element', () => {
const ref = React.createRef();
renderInLightMode(<Button ref={ref} content='Click'/>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('does not throw when a Button is used as a controlled Modal trigger and the document is clicked', () => {
// Reproduces the Control page crash: an open Modal whose trigger is a themed Button. A document click
// invokes the Portal's handleDocumentClick -> doesNodeContainClick(triggerRef.current, e).
renderInLightMode(
<Modal open trigger={<Button content='Open'/>}>
<Modal.Content>content</Modal.Content>
</Modal>
);
expect(() => {
document.body.dispatchEvent(new MouseEvent('click', {bubbles: true}));
}).not.toThrow();
});
});
describe('Progress component theme support', () => {
describe('dark mode', () => {
it('adds inverted-progress-text class for label readability', () => {
const {container} = renderInDarkMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).toHaveClass('inverted-progress-text');
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toHaveTextContent('39%');
const label = container.querySelector('.ui.progress > .label');
expect(label).toHaveTextContent('2.1 GB / 5.3 GB');
});
it('does NOT add inverted-progress-text in light mode', () => {
const {container} = renderInLightMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).not.toHaveClass('inverted-progress-text');
});
});
describe('percent text contrast per color', () => {
allColors.forEach(color => {
it(`${color} bar has readable percent text in light mode`, () => {
const {container} = renderInLightMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
it(`${color} bar has readable percent text in dark mode`, () => {
const {container} = renderInDarkMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color) || darkModeLightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
});
});
});

View file

@ -1,99 +1,5 @@
import React, {useContext, useEffect, useState, forwardRef, HTMLAttributes, CSSProperties} from 'react';
import React, {useEffect, useState} from 'react';
import {ThemeContext} from "../contexts/contexts";
import {
Accordion as SAccordion,
AccordionContent as SAccordionContent,
AccordionTitle as SAccordionTitle,
Breadcrumb as SBreadcrumb,
Button as SButton,
ButtonGroup as SButtonGroup,
Card as SCard,
Divider as SDivider,
Form as SForm,
FormField as SFormField,
FormGroup as SFormGroup,
FormInput as SFormInput,
Header as SHeader,
Icon as SIcon,
List as SList,
ListContent as SListContent,
ListDescription as SListDescription,
ListHeader as SListHeader,
ListIcon as SListIcon,
ListItem as SListItem,
Loader as SLoader,
Menu as SMenu,
MenuItem as SMenuItem,
MenuHeader as SMenuHeader,
MenuMenu as SMenuMenu,
Modal as SModal,
ModalActions as SModalActions,
ModalContent as SModalContent,
ModalDescription as SModalDescription,
ModalHeader as SModalHeader,
Placeholder as SPlaceholder,
Popup as SPopup,
Progress as SProgress,
Ref as SRef,
Segment as SSegment,
Statistic as SStatistic,
StatisticGroup as SStatisticGroup,
Tab as STab,
Table as STable,
TableBody as STableBody,
TableCell as STableCell,
TableFooter as STableFooter,
TableHeader as STableHeader,
TableHeaderCell as STableHeaderCell,
TableRow as STableRow,
TabPane as STabPane,
TextArea as STextArea,
AccordionContentProps,
AccordionProps,
AccordionTitleProps,
BreadcrumbProps,
ButtonProps,
ButtonGroupProps,
CardProps as SCardProps,
DividerProps,
FormProps,
FormFieldProps,
FormGroupProps,
FormInputProps,
HeaderProps,
IconProps,
ListProps,
ListItemProps,
ListIconProps,
ListContentProps,
ListHeaderProps,
ListDescriptionProps,
LoaderProps,
MenuProps,
MenuItemProps,
MenuMenuProps,
ModalProps,
ModalActionsProps,
ModalContentProps,
ModalDescriptionProps,
ModalHeaderProps,
PlaceholderProps,
PopupProps,
ProgressProps,
SegmentProps,
StatisticProps,
StatisticGroupProps,
TabProps,
TableProps,
TableBodyProps,
TableCellProps,
TableFooterProps,
TableHeaderProps,
TableHeaderCellProps,
TableRowProps,
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
import {MantineProvider} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
@ -101,7 +7,6 @@ import {
amberTheme,
darkTheme,
defaultTheme,
explicitOnlyThemes,
isDarkTheme,
isThemeName,
lightTheme,
@ -114,13 +19,11 @@ import {
themeNames,
themeSessionKey,
} from "../themes/names";
import {ColorToSemanticHexColor} from "./Vars";
import _ from "lodash";
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
// Theme names and picker choices live in themes/names so the component library can use
// them without importing this module (which still pulls in Semantic UI). Re-exported
// here because most call sites already import them from Theme.
// Theme names and picker choices live in themes/names, and are re-exported here because
// most call sites already import them from Theme.
export {
amberTheme,
darkTheme,
@ -289,26 +192,7 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
const dark = isDarkTheme(theme);
// Compatibility layer for components which have not been migrated to tokens yet. Semantic
// has no concept of the night/amber themes, so they reuse its dark treatment while pulling
// their colors from the tokens. These values disappear with the last Semantic component.
const i = dark ? {inverted: true} : {inverted: undefined};
const inverted = dark ? 'inverted' : '';
let s: {style?: CSSProperties} = {};
let t: {style?: CSSProperties} = {};
if (theme === darkTheme) {
s = {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}};
t = {style: {color: '#eeeeee'}};
} else if (explicitOnlyThemes.includes(theme)) {
s = {style: {backgroundColor: 'var(--panel)', color: 'var(--text)'}};
t = {style: {color: 'var(--text)'}};
}
const themeValue: ThemeContextValue = {
i, // Used for Semantic elements which support "inverted".
s, // Used to invert the style some elements.
t, // Used to invert text.
inverted, // Used to add "invert" to className.
theme,
savedTheme,
isDark: dark,
@ -336,526 +220,16 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
</MantineProvider>
</ThemeContext.Provider>
}
// Simple wrappers for Semantic UI elements to use the current theme.
const invertedNull = <T extends {inverted?: boolean | null}>(props: T): T => {
if (props['inverted'] === true) {
return {...props, inverted: null};
}
return props;
}
const defaultGrey = <T extends object>(props: T, inverted: boolean): T => {
// Some elements look softer when grey, use grey only if another color is not provided.
if (inverted) {
return {color: 'grey', ...props} as T;
}
return props;
}
// ----------------------------------------------------------------------------
// Accordion Compound Component
// ----------------------------------------------------------------------------
type AccordionTitleComponent = React.FC<AccordionTitleProps>;
type AccordionContentComponent = React.FC<AccordionContentProps>;
interface AccordionComponent extends React.FC<AccordionProps> {
Title: AccordionTitleComponent;
Content: AccordionContentComponent;
}
const AccordionTitle: AccordionTitleComponent = (props) => {
return <SAccordionTitle {...props}/>
};
const AccordionContent: AccordionContentComponent = (props) => {
return <SAccordionContent {...props}/>
};
const AccordionBase: React.FC<AccordionProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SAccordion {...mergedProps}/>
};
export const Accordion: AccordionComponent = Object.assign(AccordionBase, {
Title: AccordionTitle,
Content: AccordionContent,
});
// ----------------------------------------------------------------------------
// Button Compound Component
// ----------------------------------------------------------------------------
type ButtonGroupComponent = React.FC<ButtonGroupProps>;
interface ButtonComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<any>> {
Group: ButtonGroupComponent;
}
const ButtonGroup: ButtonGroupComponent = (props) => {
return <SButtonGroup {...props}/>
};
const ButtonBase = forwardRef<any, ButtonProps>((props, ref) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
// SButton is a class component, so a forwarded `ref` would resolve to the class instance rather than the DOM
// node. Semantic UI's Modal/Popup use the trigger's ref with `node.contains(...)`, which throws on a non-DOM
// value. Wrap in Ref (findDOMNode) so the forwarded ref always resolves to the underlying <button> element.
if (ref) {
return <SRef innerRef={ref}><SButton {...mergedProps}/></SRef>
}
return <SButton {...mergedProps}/>
});
ButtonBase.displayName = 'ButtonBase';
export const Button: ButtonComponent = Object.assign(ButtonBase, {
Group: ButtonGroup,
});
export function Divider(props: DividerProps) {
const {i} = useContext(ThemeContext);
return <SDivider {...i} {...props}/>
}
export function Header(props: HeaderProps) {
const {t} = useContext(ThemeContext);
// Merge the caller's style on top of the theme's text style; a bare `{...props}`
// would let an incoming `style` prop replace (and drop) the theme's text color.
return <SHeader {...t} {...props} style={{...t.style, ...props.style}}/>
}
export function Icon(props: IconProps) {
const {i} = useContext(ThemeContext);
return <SIcon {...i} {...props}/>
}
export function Loader(props: LoaderProps) {
const {i} = useContext(ThemeContext);
return <SLoader {...i} {...props}/>
}
// Menu is a compound component (Menu.Item / Menu.Header / Menu.Menu) so callers get the same API as Semantic's
// Menu while inheriting the theme. The parent menu is `inverted` in dark mode so item text stays readable.
const MenuBase: React.FC<MenuProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SMenu {...i} {...props}/>;
};
const MenuItem: React.FC<MenuItemProps> = (props) => <SMenuItem {...props}/>;
const MenuHeader: React.FC<MenuItemProps> = (props) => <SMenuHeader {...props}/>;
const MenuMenu: React.FC<MenuMenuProps> = (props) => <SMenuMenu {...props}/>;
interface MenuComponent extends React.FC<MenuProps> {
Item: React.FC<MenuItemProps>;
Header: React.FC<MenuItemProps>;
Menu: React.FC<MenuMenuProps>;
}
export const Menu: MenuComponent = Object.assign(MenuBase, {
Item: MenuItem,
Header: MenuHeader,
Menu: MenuMenu,
});
export function Placeholder(props: PlaceholderProps) {
const {i} = useContext(ThemeContext);
return <SPlaceholder {...i} {...props}/>
}
export function Popup(props: PopupProps) {
const {i} = useContext(ThemeContext);
return <SPopup {...i} style={{border: '1px solid grey'}} {...props}/>
}
// Bar colors that always need dark percent text (light bars in any theme).
const PROGRESS_BAR_COLORS = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const DARK_MODE_PROGRESS_BAR_COLORS = ['grey', 'blue'];
export function Progress(props: ProgressProps) {
const {i, inverted} = useContext(ThemeContext);
const classes = [props.className || ''];
if (inverted) classes.push('inverted-progress-text');
const color = props.color as string;
if (PROGRESS_BAR_COLORS.includes(color) || (inverted && DARK_MODE_PROGRESS_BAR_COLORS.includes(color))) {
classes.push('light-bar-progress-text');
}
return <SProgress {...i} {...props} className={classes.join(' ').trim()}/>
}
export function Segment(props: SegmentProps) {
const {i} = useContext(ThemeContext);
return <SSegment {...i} {...props}/>
}
// Table is now a compound component - see COMPOUND COMPONENTS section below
export function TextArea(props: TextAreaProps) {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STextArea {...mergedProps}/>
}
// ============================================================================
// COMPOUND COMPONENTS
// ============================================================================
// ----------------------------------------------------------------------------
// Modal Compound Component
// ----------------------------------------------------------------------------
type ModalActionsComponent = React.FC<ModalActionsProps>;
type ModalContentComponent = React.FC<ModalContentProps>;
type ModalDescriptionComponent = React.FC<ModalDescriptionProps>;
type ModalHeaderComponent = React.FC<ModalHeaderProps>;
interface ModalComponent extends React.FC<ModalProps> {
Actions: ModalActionsComponent;
Content: ModalContentComponent;
Description: ModalDescriptionComponent;
Header: ModalHeaderComponent;
}
const ModalActions: ModalActionsComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalActions {...props} className={`${inverted}`}/>
};
const ModalContent: ModalContentComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalContent {...props} className={`${inverted}`}/>
};
const ModalDescription: ModalDescriptionComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalDescription {...props} className={`${inverted}`}/>
};
const ModalHeader: ModalHeaderComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalHeader {...props} className={`${inverted}`}>
{props.children}
</SModalHeader>
};
const ModalBase: React.FC<ModalProps> = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModal {...props} className={`${inverted}`}/>
};
export const Modal: ModalComponent = Object.assign(ModalBase, {
Actions: ModalActions,
Content: ModalContent,
Description: ModalDescription,
Header: ModalHeader,
});
// ----------------------------------------------------------------------------
// List Compound Component
// ----------------------------------------------------------------------------
// Semantic's List supports `inverted`; the inverted class on the parent handles the item text,
// but List.Icon must also receive `inverted` so its color flips in dark mode (the parent's
// inverted class alone does not restyle the icon).
interface ListComponent extends React.FC<ListProps> {
Item: React.FC<ListItemProps>;
Icon: React.FC<ListIconProps>;
Content: React.FC<ListContentProps>;
Header: React.FC<ListHeaderProps>;
Description: React.FC<ListDescriptionProps>;
}
const ListBase: React.FC<ListProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SList {...i} {...props}/>
};
const ListIcon: React.FC<ListIconProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SListIcon {...i} {...props}/>
};
export const List: ListComponent = Object.assign(ListBase, {
Item: SListItem,
Icon: ListIcon,
Content: SListContent,
Header: SListHeader,
Description: SListDescription,
});
// ----------------------------------------------------------------------------
// Confirm Component
// ----------------------------------------------------------------------------
export interface ConfirmProps {
open: boolean;
header?: string;
content?: string | React.ReactNode;
confirmButton?: string;
cancelButton?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen';
}
export const Confirm: React.FC<ConfirmProps> = ({
open,
header,
content,
confirmButton = 'OK',
cancelButton = 'Cancel',
onConfirm,
onCancel,
onClose,
size = 'tiny',
}) => {
const {i} = useContext(ThemeContext);
return (
<Modal open={open} onClose={onClose} size={size} className="themed-confirm">
{header && <Modal.Header>{header}</Modal.Header>}
<Modal.Content>
{typeof content === 'string' ? <p>{content}</p> : content}
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel} {...i}>{cancelButton}</Button>
<Button onClick={onConfirm} color="green" {...i}>{confirmButton}</Button>
</Modal.Actions>
</Modal>
);
};
// ----------------------------------------------------------------------------
// Form Compound Component
// ----------------------------------------------------------------------------
type FormFieldComponent = React.FC<FormFieldProps>;
type FormGroupComponent = React.FC<FormGroupProps>;
type FormInputComponent = React.FC<FormInputProps>;
interface FormComponent extends React.FC<FormProps> {
Field: FormFieldComponent;
Group: FormGroupComponent;
Input: FormInputComponent;
}
const FormField: FormFieldComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormField {...mergedProps}/>
};
const FormGroup: FormGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormGroup {...mergedProps}/>
};
const FormInput: FormInputComponent = (props) => {
const {i} = useContext(ThemeContext);
return <SFormInput {...i} {...props}/>
};
const FormBase: React.FC<FormProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SForm {...i} {...props}/>
};
export const Form: FormComponent = Object.assign(FormBase, {
Field: FormField,
Group: FormGroup,
Input: FormInput,
});
// ----------------------------------------------------------------------------
// Card Compound Component
// ----------------------------------------------------------------------------
interface CardIconProps extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
children?: React.ReactNode;
}
type CardIconComponent = React.FC<CardIconProps>;
interface CardProps extends Omit<SCardProps, 'color'> {
color?: string;
}
interface CardComponent extends React.FC<CardProps> {
Icon: CardIconComponent;
}
const CardIcon: CardIconComponent = ({onClick, children, ...props}) => {
const {inverted} = useContext(ThemeContext);
const cardIcon = <center className={`card-icon ${inverted}`} {...props}>{children}</center>;
if (onClick) {
return <div onClick={onClick} className='clickable'>
{cardIcon}
</div>
} else {
return cardIcon;
}
};
const CardBase: React.FC<CardProps> = ({color, ...props}) => {
const {inverted} = useContext(ThemeContext);
const style: CSSProperties = props.style || {};
const emphasisColor = ColorToSemanticHexColor(color);
if (emphasisColor) {
// Increase drop shadow to emphasize color.
const borderColor = inverted ? '#888' : '#ddd';
style.boxShadow = `0 0 0 2px ${borderColor}, 0 5px 0 0 ${emphasisColor}, 0 0px 3px 0 #d4d4d5`;
}
return <SCard {...props} style={style}/>
};
export const Card: CardComponent = Object.assign(CardBase, {
Icon: CardIcon,
});
// ----------------------------------------------------------------------------
// Tab Compound Component
// ----------------------------------------------------------------------------
type TabPaneComponent = React.FC<TabPaneProps>;
interface TabComponent extends React.FC<TabProps> {
Pane: TabPaneComponent;
}
const TabPane: TabPaneComponent = (props) => {
const {i} = useContext(ThemeContext);
return <STabPane {...i} {...props}/>
};
const TabBase: React.FC<TabProps> = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STab {...mergedProps}/>
};
export const Tab: TabComponent = Object.assign(TabBase, {
Pane: TabPane,
});
// ----------------------------------------------------------------------------
// Statistic Compound Component
// ----------------------------------------------------------------------------
type StatisticGroupComponent = React.FC<StatisticGroupProps>;
interface StatisticComponent extends React.FC<StatisticProps> {
Group: StatisticGroupComponent;
}
const StatisticGroup: StatisticGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const style: CSSProperties = {...props.style, marginLeft: 0, marginRight: 0};
return <SStatisticGroup {...i} {...props} style={style}/>
};
const StatisticBase: React.FC<StatisticProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SStatistic {...mergedProps}/>
};
export const Statistic: StatisticComponent = Object.assign(StatisticBase, {
Group: StatisticGroup,
});
// ----------------------------------------------------------------------------
// Breadcrumb Compound Component
// ----------------------------------------------------------------------------
interface BreadcrumbDividerProps {
icon?: string;
}
type BreadcrumbDividerComponent = React.FC<BreadcrumbDividerProps>;
interface BreadcrumbComponent extends React.FC<BreadcrumbProps> {
Divider: BreadcrumbDividerComponent;
}
const BreadcrumbDivider: BreadcrumbDividerComponent = ({icon, ...props}) => {
const {inverted} = useContext(ThemeContext);
// TODO this only handles icons for now.
const className = `divider icon ${inverted} ${icon || ''}`;
return <i aria-hidden="true" className={className}></i>
};
const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
const {t} = useContext(ThemeContext);
return <SBreadcrumb {...props} {...t}/>
};
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
Divider: BreadcrumbDivider,
});
// ----------------------------------------------------------------------------
// Table Compound Component
// ----------------------------------------------------------------------------
type TableBodyComponent = React.FC<TableBodyProps>;
type TableCellComponent = React.FC<TableCellProps>;
type TableFooterComponent = React.FC<TableFooterProps>;
type TableHeaderComponent = React.FC<TableHeaderProps>;
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
type TableRowComponent = React.FC<TableRowProps>;
interface TableComponent extends React.FC<TableProps> {
Body: TableBodyComponent;
Cell: TableCellComponent;
Footer: TableFooterComponent;
Header: TableHeaderComponent;
HeaderCell: TableHeaderCellComponent;
Row: TableRowComponent;
}
const TableBody: TableBodyComponent = (props) => {
return <STableBody {...props}/>
};
const TableCell: TableCellComponent = (props) => {
return <STableCell {...props}/>
};
const TableFooter: TableFooterComponent = (props) => {
return <STableFooter {...props}/>
};
const TableHeader: TableHeaderComponent = (props) => {
return <STableHeader {...props}/>
};
const TableHeaderCell: TableHeaderCellComponent = (props) => {
return <STableHeaderCell {...props}/>
};
const TableRow: TableRowComponent = (props) => {
return <STableRow {...props}/>
};
const TableBase: React.FC<TableProps> = (props) => {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
};
export const Table: TableComponent = Object.assign(TableBase, {
Body: TableBody,
Cell: TableCell,
Footer: TableFooter,
Header: TableHeader,
HeaderCell: TableHeaderCell,
Row: TableRow,
});
/*
* The Semantic UI wrappers that used to live below this line are gone.
*
* They existed so call sites could get a themed Button, Segment, Modal and so on while
* the app still rendered Semantic components; every one of those call sites now imports
* from `src/components/ui` instead. The dark-mode compatibility props they consumed --
* `i`, `s`, `t`, `inverted` -- went with them: those carried hardcoded greys, which is
* precisely what the token themes replaced.
*
* What remains is the part that was never about Semantic: the provider that resolves the
* chosen theme, stamps `data-theme` and `data-media-filter` on <html>, persists both, and
* hands Mantine our tokens.
*/

View file

@ -382,20 +382,26 @@ export function ThemeSamplePage() {
<Section label='Cards'>
<CardGroup>
{[
['How To Sharpen An Axe The Right Way', 'Wranglerstar · 2024-11-03'],
['Pressure Canning Basics', 'RoseRed Homestead · 2025-02-17'],
['Solar Panel Wiring: Series vs Parallel', 'DIY Solar Power · 2025-08-21'],
['Ham Radio General License, Part 1', 'Ham Radio Crash Course · 2024-05-09'],
].map(([title, meta]) => <Card
['How To Sharpen An Axe The Right Way', 'Wranglerstar · 2024-11-03', 'blue', 'film'],
['Pressure Canning Basics', 'RoseRed Homestead · 2025-02-17', 'blue', 'film'],
['Water Storage Guide.pdf', 'docs/ · 2025-08-21', 'red', 'file pdf'],
['Ham Radio General License.epub', 'ebooks/ · 2024-05-09', 'yellow', 'book'],
].map(([title, meta, color, icon]) => <Card
key={title}
title={title}
meta={meta}
color={color}
media={<div style={{
aspectRatio: '16/9', background: 'var(--head)', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: 'var(--muted)',
}}><Icon name='film' size={34}/></div>}
}}><Icon name={icon} size={34}/></div>}
/>)}
</CardGroup>
<p style={{fontSize: 12, color: 'var(--muted)', marginTop: 10}}>
The bottom edge carries the file type's colour, so a grid of results is
scannable by kind before any title is read. It comes from a token, so night
and amber render the accent in their own single hue rather than four.
</p>
</Section>
<Section label='Loading placeholders'>
@ -410,6 +416,9 @@ export function ThemeSamplePage() {
<Row>
<Button role='cancel' icon='eye' onClick={() => setModalOpen(true)}>Open a modal</Button>
</Row>
<div style={{marginTop: 12, maxWidth: 320}}>
<Progress indeterminate label='Uploading…'/>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0, marginTop: 10}}>
Semantic's compound shape is kept — Modal.Header, Modal.Content,
Modal.Actions — so the 34 call sites written against it migrate by import.

View file

@ -1,16 +1,11 @@
import React from "react";
import {useDropzone} from "react-dropzone";
import {Form, Header, Icon, Progress, Segment} from "./Theme";
import {DirectorySearch, mimetypeIconName, Toggle} from "./Common";
import {Box, Group, Header, Icon, Panel, Progress, Stack} from "./ui";
import {DirectorySearch, mimetypeIconName, RequiredAsterisk, Toggle} from "./Common";
import {useUploadFile} from "../hooks/customHooks";
import _ from "lodash";
import {ThemeContext} from "../contexts/contexts";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form as SForm} from "semantic-ui-react";
export function Upload({disabled}) {
const {t} = React.useContext(ThemeContext);
const {
setFiles,
progresses,
@ -57,33 +52,29 @@ export function Upload({disabled}) {
color = 'orange';
statusString = 'Already Exists:';
}
// Only indicating when upload is pending.
let indicating = status === 'pending';
return <Grid.Row key={name}>
<Grid.Column mobile={3} tablet={2} computer={1}>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='big'/>
</Grid.Column>
<Grid.Column mobile={13} tablet={14} computer={15}>
<Progress progress indicating={indicating} percent={percent} color={color}>
<p {...t}>{statusString} {name}</p>
</Progress>
</Grid.Column>
</Grid.Row>
return <Group key={name} align='center' wrap='nowrap' gap='sm'>
<Icon name={mimetypeIconName(type, name.toLowerCase())} size='large'/>
<Box style={{flex: 1}}>
<Progress percent={percent} color={color} label={`${statusString} ${name}`}/>
</Box>
</Group>
})
}
return <>
<Form>
<SForm.Field required>
<label>Destination</label>
<Stack gap='sm'>
<div>
<label style={{display: 'block', marginBottom: 4}}>
<b>Destination</b> <RequiredAsterisk/>
</label>
<DirectorySearch
onSelect={i => setDestination(i)}
disabled={disabled || inProgress}
style={{marginBottom: '0.5em'}}
/>
{tagsSelector}
</SForm.Field>
</div>
<Toggle
checked={overwrite}
@ -91,50 +82,30 @@ export function Upload({disabled}) {
onChange={() => setOverwrite(!overwrite)}
disabled={disabled || inProgress}
/>
</Form>
</Stack>
{destination ?
<Segment>
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Header icon>
<Icon name='file text'/>
Click here, or drop files here to upload
</Header>
</Grid.Column>
</Grid.Row>
</Grid>
</div>
</Form>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Panel style={{marginTop: '1em'}}>
<div {...getRootProps()} style={{padding: '1em', textAlign: 'center', cursor: 'pointer'}}>
<input {...getInputProps()}/>
<Header as='h3' icon='file text'>
Click here, or drop files here to upload
</Header>
</div>
</Panel>
: <Header as='h3'>You must search for a directory to place your files</Header>
}
<br/>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progresses && Object.keys(progresses).length > 1 && <Progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
<Stack gap='sm' mt='sm'>
{progressBars}
</Grid>
</Stack>
</>
}

View file

@ -1,4 +1,4 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
import {deleteFileGroups, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
import {Link, useNavigate, useParams} from "react-router";
@ -17,17 +17,14 @@ import {
useMediaSession,
useTitle
} from "./Common";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Container from "semantic-ui-react/dist/commonjs/elements/Container";
import {VideoPlaceholder} from "./Placeholder";
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {ThemeContext} from "../contexts/contexts";
import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Tabs, TextInput} from "./ui";
import {VideoCard} from "./Videos";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {IconPlayerPause, IconPlayerPlay} from "@tabler/icons-react";
const MEDIA_PATH = '/media';
@ -117,7 +114,7 @@ const NoComments = ({video}) => {
<p>No comments have been been downloaded. Refresh the video?</p>
<APIButton
color='blue'
role='primary'
size='large'
onClick={handleRefresh}
>Refresh</APIButton>
@ -132,43 +129,43 @@ const NoComments = ({video}) => {
}
}
// Semantic's <Comment>/<CommentGroup> have no equivalent in the token library -- this is a nested
// comment thread, not a reusable widget, so it is built here from plain elements and tokens.
const VideoComment = ({comment, children}) => {
const {t} = React.useContext(ThemeContext);
const {is_favorited, like_count, author, timestamp, text, author_is_uploader} = comment;
// Author comments and favorited comments are important.
let specialIcon = null;
if (author_is_uploader) {
specialIcon = <Icon name='star' color='green'/>;
specialIcon = <Icon name='star' label='Uploader' style={{color: 'var(--green)'}}/>;
} else if (is_favorited) {
specialIcon = <Icon name='heart' color='red'/>;
specialIcon = <Icon name='heart' label='Favorited' style={{color: 'var(--red)'}}/>;
}
const dateElm = <div {...t}>{isoDatetimeToAgoPopup(timestamp * 1000)}</div>;
const likesElm = like_count && <div {...t}>
<Icon name='thumbs up'/>{humanNumber(comment['like_count'])}
</div>;
return <Comment>
<Comment.Content>
<Comment.Author as='a'>
<span {...t}>{specialIcon}{author}</span>
</Comment.Author>
<Comment.Metadata>
{dateElm}
{likesElm}
</Comment.Metadata>
<Comment.Text {...t}>
<MultilineText text={text} style={{marginLeft: '0.5em'}}/>
</Comment.Text>
</Comment.Content>
return <div style={{marginBottom: '1.2em'}}>
<div style={{fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.3em'}}>
{specialIcon}<span>{author}</span>
</div>
<div style={{display: 'flex', gap: '1em', fontSize: '0.85em', color: 'var(--muted)'}}>
<div>{isoDatetimeToAgoPopup(timestamp * 1000)}</div>
{like_count ? <div>
<Icon name='thumbs up' size={14}/> {humanNumber(comment['like_count'])}
</div> : null}
</div>
<div>
<MultilineText text={text} style={{marginLeft: '0.5em'}}/>
</div>
{children && !_.isEmpty(children) &&
<CommentGroup>
<div style={{
marginLeft: '1.5em',
marginTop: '0.7em',
paddingLeft: '1em',
borderLeft: '1px solid var(--border)',
}}>
{children.map((i, key) => <VideoComment key={key} comment={i}/>)}
</CommentGroup>
</div>
}
</Comment>
</div>
}
@ -202,9 +199,9 @@ const Comments = ({comments, video}) => {
groupedByParent[obj.parent] = _.sortBy(children, ['timestamp']);
})
return <CommentGroup>
return <div>
{rootComments.map((i, key) => <VideoComment key={key} comment={i} children={groupedByParent[i.id]}/>)}
</CommentGroup>
</div>
}
// Generate a video page URL for a file object.
@ -214,11 +211,10 @@ function getVideoLink(file) {
}
function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const {theme} = useContext(ThemeContext);
const navigate = useNavigate();
const videoRef = React.useRef();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
const [activeTab, setActiveTab] = useState('comments');
useTitle(videoFile ? videoFile.title ? videoFile.title : videoFile.name : null);
let video;
@ -353,26 +349,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
description = formatVideoDescription(description, setVideoTime)
}
const descriptionPane = {
menuItem: 'Description', render: () => <Tab.Pane>
<pre className="wrap-text">
{description}
</pre>
</Tab.Pane>
};
const captionsPane = {
menuItem: 'Captions', render: () => <CaptionsPane
captions={captions}
captionsLoading={captionsLoading}
fetchCaptions={fetchCaptions}
setVideoTime={setVideoTime}
/>
};
const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix));
const fileSize = (file) => file && file.size
? <span style={{marginLeft: '1em', color: 'grey'}}>({humanFileSize(file.size)})</span>
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
: null;
const {poster_file, info_json_file} = video;
@ -380,120 +359,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const ffprobeJsonFile = getFile('.ffprobe.json');
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const filesPane = {
menuItem: 'Files', render: () => <Tab.Pane>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
<td>
{video['video_path']
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
{video['video_path']}
</PreviewPath>{fileSize(videoPathFile)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Caption Files</strong></td>
<td>
{caption_files
? caption_files.map(i => <div key={i['path']} style={{marginBottom: '0.3em'}}>
<PreviewPath {...i} taggable={false}>{i['path']}</PreviewPath>
{fileSize(i)}
</div>)
: 'No caption files'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Poster File</strong></td>
<td>
{poster_file
? <><PreviewPath path={poster_file['path']} mimetype={poster_file['mimetype']}
taggable={false}>
{poster_file['path']}
</PreviewPath>{fileSize(poster_file)}</>
: 'No poster file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Info JSON File</strong></td>
<td>
{info_json_file
? <><PreviewPath path={info_json_file['path']} mimetype={info_json_file['mimetype']}
taggable={false}>
{info_json_file['path']}
</PreviewPath>{fileSize(info_json_file)}</>
: 'No yt-dlp info json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>ffprobe JSON File</strong></td>
<td>
{ffprobeJsonFile
? <><PreviewPath path={ffprobeJsonFile['path']} mimetype={ffprobeJsonFile['mimetype']}
taggable={false}>
{ffprobeJsonFile['path']}
</PreviewPath>{fileSize(ffprobeJsonFile)}</>
: 'No ffprobe json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={videoFile['directory']}/></td>
</tr>
</tbody>
</table>
</Tab.Pane>
}
const commentsPane = {
menuItem: 'Comments', render: () => <Tab.Pane>
<Header as='h3'>Top Comments</Header>
<Comments comments={comments} video={videoFile}/>
</Tab.Pane>,
};
const tabPanes = [commentsPane, descriptionPane, filesPane, captionsPane];
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
const aboutSegment = <Segment>
<Header as='h2'>About Video</Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<h3>Size</h3>
<p>{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}</p>
</Grid.Column>
<Grid.Column>
<h3>View Count</h3>
<p>{video.view_count ? humanNumber(video.view_count) : 'N/A'}</p>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<h3>Codec Names</h3>
<>{video.codec_names ? video.codec_names.map(i => <Label key={i}>{i}</Label>) : 'N/A'}</>
</Grid.Column>
<Grid.Column>
<h3>Censored</h3>
<p>{videoFile.censored ? 'Yes' : 'No'}</p>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={1}>
<Grid.Column>
<h3>Source URL</h3>
<p>{videoFile.url ? <a href={videoFile.url}>{videoFile.url}</a> : 'N/A'}</p>
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>;
const localAddTag = async (name) => {
await tagFileGroup(videoFile, name);
@ -508,20 +373,18 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
let videoSource = <source src={videoUrl}/>;
const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/');
let prevNextVideosSegment = <Segment><p>No related videos found.</p></Segment>;
let prevNextVideosPanel = <Panel><p>No related videos found.</p></Panel>;
if (prevFile || nextFile) {
prevNextVideosSegment = <Segment>
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column textAlign='left'>
{prevFile && <><Header as='h3'>Older</Header><VideoCard file={prevFile}/></>}
</Grid.Column>
<Grid.Column textAlign='left'>
{nextFile && <><Header as='h3'>Newer</Header><VideoCard file={nextFile}/></>}
</Grid.Column>
</Grid.Row>
prevNextVideosPanel = <Panel>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
{prevFile && <><Header as='h3'>Older</Header><VideoCard file={prevFile}/></>}
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
{nextFile && <><Header as='h3'>Newer</Header><VideoCard file={nextFile}/></>}
</Grid.Col>
</Grid>
</Segment>
</Panel>
}
let title = video.video_path;
@ -531,19 +394,35 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
title = videoFile.stem;
}
const audioPlayPauseLabel = audioPlaying ? 'Pause' : 'Play';
return <>
<Container style={{margin: '1em'}}>
<div style={{margin: '1em'}}>
<BackButton/>
</Container>
</div>
{isAudio ? <>
{posterUrl && <div
className='audio-poster-container'
// A wrapper whose custom play/pause/volume glyphs are drawn over the poster image: the whole
// composite must be tinted as one unit in night mode, so it carries `.media` even though the
// <img/> inside is already a filtered leaf on its own.
className='audio-poster-container media'
role='button'
tabIndex={0}
aria-label={audioPlaying ? 'Pause audio' : 'Play audio'}
onClick={() => {
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}
}}
onMouseEnter={() => setAudioPosterHover(true)}
onMouseLeave={() => setAudioPosterHover(false)}
>
@ -552,16 +431,24 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
alt={mediaTitle || 'Audio thumbnail'}
style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}}
/>
<Transition visible={showAudioOverlay} animation='fade' duration={500}>
<div className='audio-poster-overlay'>
<Icon name='volume up' size='massive'/>
</div>
</Transition>
<Transition visible={audioPosterHover && !showAudioOverlay} animation='fade' duration={200}>
<div className='audio-poster-overlay'>
<Icon name={audioPlaying ? 'pause circle outline' : 'play circle outline'} size='massive'/>
</div>
</Transition>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{opacity: showAudioOverlay ? 1 : 0, transition: 'opacity 500ms ease'}}
>
<Icon name='volume up' size={64}/>
</div>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{
opacity: (audioPosterHover && !showAudioOverlay) ? 1 : 0,
transition: 'opacity 200ms ease',
}}
>
<Icon component={audioPlaying ? IconPlayerPause : IconPlayerPlay} size={64}
label={audioPlayPauseLabel}/>
</div>
</div>}
<audio controls
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
@ -585,8 +472,8 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
{captionUrls.map(i => <CaptionTrack key={i} src={i}/>)}
</video>}
<Container style={{marginTop: '1em'}}>
<Segment>
<div style={{marginTop: '1em'}}>
<Panel>
<Header as='h2'>{title}</Header>
{videoFile.published_datetime && <h3>{isoDatetimeToAgoPopup(videoFile.published_datetime)}</h3>}
@ -597,37 +484,158 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
</h3>
<p>
<Button as='a' href={downloadUrl}>
<Icon name='download'/>
<Button component='a' href={downloadUrl} icon='download'>
Download
</Button>
<APIButton
color='red'
role='danger'
confirmContent='Are you sure you want to delete this video? All files related to this video will be deleted. It will not be downloaded again!'
confirmButton='Delete'
onClick={async () => await handleDeleteVideo(videoFile.id)}
obeyWROLMode={true}
>Delete</APIButton>
<APIButton
color='blue'
role='primary'
onClick={handleRefresh}
obeyWROLMode={true}
disabled={!videoFile.url}
>Refresh</APIButton>
<AddToPlaylistButton fileGroupId={videoFile.id}/>
</p>
</Segment>
</Panel>
<Segment>
<Panel>
<TagsSelector selectedTagNames={videoFile['tags']} onAdd={localAddTag} onRemove={localRemoveTag}/>
</Segment>
</Panel>
{aboutSegment}
<Panel>
<Header as='h2'>About Video</Header>
<Tab menu={tabMenu} panes={tabPanes}/>
<Grid>
<Grid.Col span={6}>
<h3>Size</h3>
<p>{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>View Count</h3>
<p>{video.view_count ? humanNumber(video.view_count) : 'N/A'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>Codec Names</h3>
<>{video.codec_names ? video.codec_names.map(i => <Label key={i}>{i}</Label>) : 'N/A'}</>
</Grid.Col>
<Grid.Col span={6}>
<h3>Censored</h3>
<p>{videoFile.censored ? 'Yes' : 'No'}</p>
</Grid.Col>
<Grid.Col span={12}>
<h3>Source URL</h3>
<p>{videoFile.url ? <a href={videoFile.url}>{videoFile.url}</a> : 'N/A'}</p>
</Grid.Col>
</Grid>
</Panel>
{prevNextVideosSegment}
</Container>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value='comments'>Comments</Tabs.Tab>
<Tabs.Tab value='description'>Description</Tabs.Tab>
<Tabs.Tab value='files'>Files</Tabs.Tab>
<Tabs.Tab value='captions'>Captions</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='comments' style={{paddingTop: '1em'}}>
<Header as='h3'>Top Comments</Header>
<Comments comments={comments} video={videoFile}/>
</Tabs.Panel>
<Tabs.Panel value='description' style={{paddingTop: '1em'}}>
<pre className="wrap-text">
{description}
</pre>
</Tabs.Panel>
<Tabs.Panel value='files' style={{paddingTop: '1em'}}>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
<td>
{video['video_path']
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
{video['video_path']}
</PreviewPath>{fileSize(videoPathFile)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Caption Files</strong></td>
<td>
{caption_files
? caption_files.map(i => <div key={i['path']} style={{marginBottom: '0.3em'}}>
<PreviewPath {...i} taggable={false}>{i['path']}</PreviewPath>
{fileSize(i)}
</div>)
: 'No caption files'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Poster File</strong></td>
<td>
{poster_file
? <><PreviewPath path={poster_file['path']} mimetype={poster_file['mimetype']}
taggable={false}>
{poster_file['path']}
</PreviewPath>{fileSize(poster_file)}</>
: 'No poster file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Info JSON File</strong></td>
<td>
{info_json_file
? <><PreviewPath path={info_json_file['path']} mimetype={info_json_file['mimetype']}
taggable={false}>
{info_json_file['path']}
</PreviewPath>{fileSize(info_json_file)}</>
: 'No yt-dlp info json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>ffprobe JSON File</strong></td>
<td>
{ffprobeJsonFile
? <><PreviewPath path={ffprobeJsonFile['path']} mimetype={ffprobeJsonFile['mimetype']}
taggable={false}>
{ffprobeJsonFile['path']}
</PreviewPath>{fileSize(ffprobeJsonFile)}</>
: 'No ffprobe json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={videoFile['directory']}/></td>
</tr>
</tbody>
</table>
</Tabs.Panel>
<Tabs.Panel value='captions' style={{paddingTop: '1em'}}>
<CaptionsPane
captions={captions}
captionsLoading={captionsLoading}
fetchCaptions={fetchCaptions}
setVideoTime={setVideoTime}
/>
</Tabs.Panel>
</Tabs>
{prevNextVideosPanel}
</div>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
taggedFileGroups={taggedFileGroups}
@ -661,11 +669,11 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
}, []);
if (captionsLoading) {
return <Tab.Pane><Loader active/></Tab.Pane>;
return <Loading/>;
}
if (!captions || captions.length === 0) {
return <Tab.Pane>No captions available.</Tab.Pane>;
return <div>No captions available.</div>;
}
let filtered = captions;
@ -677,14 +685,13 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
});
}
return <Tab.Pane>
<Input
fluid
icon='filter'
return <>
<TextInput
leftSection={<Icon name='filter' size={14}/>}
placeholder='Filter captions...'
value={filter}
onChange={(e, {value}) => setFilter(value)}
style={{marginBottom: '1em'}}
onChange={(e) => setFilter(e.currentTarget.value)}
style={{marginBottom: '1em', width: '100%'}}
/>
<div style={{maxHeight: '400px', overflowY: 'auto'}}>
{filtered.map((chunk, idx) => (
@ -694,7 +701,7 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
e.preventDefault();
setVideoTime(chunk.start_seconds);
}}
style={{whiteSpace: 'nowrap', fontFamily: 'monospace'}}
style={{whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)'}}
>
{formatCaptionTimestamp(chunk.start_seconds)}
</a>
@ -702,7 +709,7 @@ function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime})
</div>
))}
</div>
</Tab.Pane>;
</>;
}
export default VideoPage;

View file

@ -15,6 +15,7 @@ import {
HelpHeader,
InfoHeader,
isoDatetimeToAgoPopup,
mimetypeColor,
PageContainer,
PreviewLink,
scrollToTop,
@ -880,7 +881,8 @@ export function VideoCard({file}) {
<div>{detailLine}</div>
</>;
return <Card media={media} title={titleElm} meta={meta}/>
return <Card media={media} title={titleElm} meta={meta}
color={mimetypeColor(file.mimetype, file.primary_path)}/>
}
export function VideoRowCells({file}) {

View file

@ -1,31 +1,5 @@
import {useOutdatedZims, useSearchZim, useWROLMode} from "../hooks/customHooks";
import {
Accordion,
Button,
Divider,
Header,
Icon,
Loader,
Modal,
Placeholder,
Segment
} from "./Theme";
import {HeadlineText} from "./Headline";
import {TextPlaceholder} from "./Placeholder";
import {
AccordionContent,
AccordionTitle,
Button as SButton,
Dropdown,
Icon as SIcon,
Label,
Message,
PlaceholderHeader,
PlaceholderLine,
TableCell,
TableRow
} from "semantic-ui-react";
import React, {useContext, useState} from "react";
import React, {useState} from "react";
import {
APIButton,
encodeMediaPath,
@ -43,7 +17,23 @@ import {
useTitle,
WarningMessage
} from "./Common";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {
Accordion,
Button,
Divider,
Group,
Header,
Label,
Loading,
Message,
Modal,
Panel,
Placeholder,
Select,
Table,
toast,
} from "./ui";
import {TextPlaceholder} from "./Placeholder";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {
@ -60,19 +50,17 @@ import {
} from "../api";
import {useSearch} from "./Search";
import {TagsQuerySelector} from "./Files";
import {ThemeContext} from "../contexts/contexts";
import {Link, Route, Routes} from "react-router";
import {SortableTable} from "./SortableTable";
import {toast} from "./ui";
import _ from "lodash";
import {ZIM_VIEWER_URI} from "./Vars";
import {HeadlineText} from "./Headline";
export const OutdatedZimsMessage = ({onClick}) => {
const [open, setOpen] = React.useState(false);
const {outdated, current} = useOutdatedZims();
const onOpen = () => setOpen(true);
const onClose = () => setOpen(false);
const handleDelete = async () => {
@ -106,9 +94,7 @@ export const OutdatedZimsMessage = ({onClick}) => {
}
}
let modalContent = <Placeholder>
<PlaceholderLine/>
</Placeholder>;
let modalContent = <Placeholder lines={1}/>;
if (outdated && outdated.length > 0) {
modalContent = <>
<Header as='h3'>To Delete</Header>
@ -119,38 +105,29 @@ export const OutdatedZimsMessage = ({onClick}) => {
</>
}
return <Message icon info>
<SIcon name='question'/>
<Message.Content>
<Message.Header>Outdated Zim Files</Message.Header>
New Zim files have been downloaded. Outdated Zim files can be removed.
<p></p>
return <Message kind='info' icon='question' title='Outdated Zim Files'>
New Zim files have been downloaded. Outdated Zim files can be removed.
<p></p>
<SButton primary onClick={() => setOpen(true)}>Delete</SButton>
<Modal closeIcon
open={open}
onClose={onClose}
onOpen={onOpen}
>
<Modal.Header>Delete</Modal.Header>
<Modal.Content>
{modalContent}
</Modal.Content>
<Modal.Actions>
<APIButton
color='red'
floated='left'
onClick={handleDelete}
>Delete</APIButton>
<Button onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
<Button role='danger' onClick={() => setOpen(true)}>Delete</Button>
<Modal open={open}
onClose={onClose}
title='Delete'
>
{modalContent}
<Modal.Actions>
<APIButton
role='danger'
onClick={handleDelete}
>Delete</APIButton>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
<Link to='/files?folders=zims'><SButton>Delete Manually</SButton></Link>
<APIButton secondary
onClick={handleIgnore}
>Ignore Forever</APIButton>
</Message.Content>
<Link to='/files?folders=zims'><Button role='cancel'>Delete Manually</Button></Link>
<APIButton role='cancel'
onClick={handleIgnore}
>Ignore Forever</APIButton>
</Message>
}
@ -188,42 +165,38 @@ const ZimSearchEntry = ({zimId, onTag, onUntag, entry}) => {
{tagIcon}
</Header>
<HeadlineText headline={headline}/>
<Modal closeIcon
open={open}
<Modal open={open}
onClose={() => setOpen(false)}>
<Modal.Content>
<div className='preview-fit'>
<ZimViewer src={url} style={{
// Override IframeViewer's `fixed` so the iframe stays inside the Modal.
position: 'static',
height: '100%', width: '100%', border: 'none',
// Use white to avoid iframe displaying with dark-theme.
backgroundColor: '#ffffff',
}}/>
</div>
</Modal.Content>
<div className='preview-fit'>
<ZimViewer src={url} style={{
// Override IframeViewer's `fixed` so the iframe stays inside the Modal.
position: 'static',
height: '100%', width: '100%', border: 'none',
// Use white to avoid iframe displaying with dark-theme.
backgroundColor: '#ffffff',
}}/>
</div>
<Modal.Actions>
<Grid>
<Grid.Column mobile={10} tablet={12}>
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
</Grid.Column>
<Grid.Column width={4} textAlign='right'>
<Group justify='space-between' align='flex-start' wrap='wrap' style={{width: '100%'}}>
<TagsSelector selectedTagNames={tag_names} onAdd={localAddTag} onRemove={localUntag}/>
<Group gap='xs'>
<AddToPlaylistButton
zim={{zimId, entry: path, title: (title || path).replace(/<[^>]*>/g, '')}}
content={null} title='Add to Playlist'/>
<Button color='blue' as='a' href={url} target='_blank' rel='noopener noreferrer'>Open</Button>
</Grid.Column>
</Grid>
<Button role='primary' component='a' href={url} target='_blank' rel='noopener noreferrer'>
Open
</Button>
</Group>
</Group>
</Modal.Actions>
</Modal>
</div>
}
const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}) => {
const ZimAccordionItem = ({value, active, data, searchStr, activeTags}) => {
const {id, estimate, metadata} = data;
const {title, date} = metadata;
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, index === activeIndex, activeTags);
const {t} = useContext(ThemeContext);
const {zim, fetchSearch, pages, loading} = useSearchZim(searchStr, id, active, activeTags);
const localAddTag = async (zimId, path, name) => {
await tagZimEntry(zimId, path, name);
@ -239,11 +212,14 @@ const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}
if (zim && !loading) {
const {search} = zim;
if (search && search.length > 0) {
body = search.map(i => <Segment vertical key={i['path']}>
body = search.map(i => <div
key={i['path']}
style={{borderBottom: '1px solid var(--border)', paddingBottom: '1em', marginBottom: '1em'}}
>
<ZimSearchEntry zimId={id} onTag={localAddTag} onUntag={localUntag} entry={i}/>
</Segment>);
</div>);
} else {
body = <p {...t}>No results</p>;
body = <p>No results</p>;
}
}
@ -253,69 +229,52 @@ const ZimAccordion = ({data, index, activeIndex, onClick, searchStr, activeTags}
const label = <Label color={estimate > 0 ? 'violet' : undefined}>{normalizeEstimate(estimate)}</Label>;
return <React.Fragment>
<AccordionTitle
index={index}
active={index === activeIndex}
onClick={() => onClick(index, activeIndex)}
>
<Header as='h3'>
<Icon name='dropdown'/> {title} {label}
</Header>
</AccordionTitle>
<AccordionContent active={index === activeIndex}>
return <Accordion.Item value={value}>
<Accordion.Control>
<Header as='h3' style={{marginBottom: 0}}>{title} {label}</Header>
</Accordion.Control>
<Accordion.Panel>
<Header as='h4'>{date}</Header>
{body}
{paginator}
</AccordionContent>
</React.Fragment>
</Accordion.Panel>
</Accordion.Item>
}
const ZimsRefreshWarning = () => {
return <Message icon warning onClick={refreshFiles}>
<SIcon name='hand point right'/>
<Message.Content>
<Message.Header>No Zims have been indexed.</Message.Header>
return <div onClick={refreshFiles} style={{cursor: 'pointer'}}>
<Message kind='warning' icon='hand point right' title='No Zims have been indexed.'>
<a href='#'>Click here</a> to refresh all your files.
</Message.Content>
</Message>;
</Message>
</div>;
}
export const ZimSearchView = ({suggestions, loading}) => {
const [activeIndex, setActiveIndex] = React.useState(null);
const [activeValue, setActiveValue] = React.useState(null);
const {searchStr, activeTags, setTags} = useSearch();
const {zimsEstimates} = suggestions;
const handleClick = (index, activeIndex_) => {
setActiveIndex(index === activeIndex_ ? -1 : index);
}
let body;
if (!_.isEmpty(zimsEstimates)) {
body = zimsEstimates.map((i, index) => <ZimAccordion
key={i['path']}
index={index}
activeIndex={activeIndex}
data={i}
searchStr={searchStr}
activeTags={activeTags}
onClick={handleClick}
/>);
body = <Accordion value={activeValue} onChange={setActiveValue}>
{zimsEstimates.map(i => <ZimAccordionItem
key={i['path']}
value={String(i['id'])}
active={activeValue === String(i['id'])}
data={i}
searchStr={searchStr}
activeTags={activeTags}
/>)}
</Accordion>;
} else if (loading) {
body = <AccordionContent>
<Segment placeholder>
<Loader active={true}/>
</Segment>
</AccordionContent>;
body = <Panel><Loading/></Panel>;
} else if (_.isEmpty(zimsEstimates)) {
body = <ZimsRefreshWarning/>;
}
return <>
<TagsQuerySelector onChange={(i, j) => setTags(i)}/>
<Accordion>
{body}
</Accordion>
{body}
</>
}
@ -369,39 +328,36 @@ const ZimCatalogItemRow = ({item, subscriptions, iso_639_codes, fetchSubscriptio
}
}
const handleLanguageChange = (e, {value}) => {
if (e) {
e.preventDefault();
}
setLanguage(value);
}
const languageOptions = languages.map(i => {
return {key: i, value: i, text: iso_639_codes[i]}
return {value: i, label: iso_639_codes[i]}
});
const languageDropdown = <Dropdown fluid search selection
placeholder='Language'
options={languageOptions}
value={language}
disabled={wrolModeEnabled}
onChange={handleLanguageChange}
const languageDropdown = <Select
searchable
placeholder='Language'
data={languageOptions}
value={language}
disabled={wrolModeEnabled}
onChange={(value) => setLanguage(value)}
/>;
const isDestructive = subscription && !languageChange;
const subscribeButton = <APIButton
color='grey'
role={isDestructive ? 'danger' : 'primary'}
confirmContent={isDestructive ? 'Are you sure you want to unsubscribe from this Zim?' : undefined}
confirmButton={isDestructive ? 'Unsubscribe' : undefined}
disabled={pending}
onClick={handleButton}
obeyWROLMode={true}
>
{subscription && !languageChange ? 'Unsubscribe' : 'Subscribe'}
{isDestructive ? 'Unsubscribe' : 'Subscribe'}
</APIButton>
return <TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell>{languageDropdown}</TableCell>
<TableCell>{subscribeButton}</TableCell>
<TableCell>{humanFileSize(size)}</TableCell>
</TableRow>
return <Table.Row key={name}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{languageDropdown}</Table.Cell>
<Table.Cell>{subscribeButton}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
</Table.Row>
}
class ManageZim extends React.Component {
@ -458,11 +414,11 @@ class ManageZim extends React.Component {
popupContent='Enable/Disable searching this Zim file in the Global Search.'
/>
return <TableRow key={path}>
<TableCell>{path}</TableCell>
<TableCell>{humanFileSize(size)}</TableCell>
<TableCell>{toggle}</TableCell>
</TableRow>
return <Table.Row key={path}>
<Table.Cell>{path}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
<Table.Cell>{toggle}</Table.Cell>
</Table.Row>
}
render() {
@ -473,15 +429,9 @@ class ManageZim extends React.Component {
{key: 'size', text: 'Size', sortBy: 'size', width: 2},
{key: 'search', text: 'Search', sortBy: 'auto_search', width: 2},
];
let zimFilesBody = <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
let zimFilesBody = <Placeholder lines={2}/>;
if (zims && zims.length >= 1) {
zimFilesBody = <SortableTable
tableProps={{striped: true}}
data={zims}
rowFunc={(i, sortData) => this.zimFileTableRow(i, sortData, this.fetchZims.bind(this))}
rowKey='path'
@ -505,12 +455,7 @@ class ManageZim extends React.Component {
{key: 'subscription', text: 'Subscription', 'sortBy': null, width: 2},
{key: 'size', text: 'Maximum Size', sortBy: 'size', width: 2},
];
let kiwixCatalog = <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
let kiwixCatalog = <Placeholder lines={2}/>;
if (catalog && catalog.length > 0) {
kiwixCatalog = <SortableTable
defaultSortColumn='name'
@ -544,7 +489,7 @@ class ManageZim extends React.Component {
}
function ZimViewer({src = ZIM_VIEWER_URI, style = null}) {
const fallback = <Segment>
const fallback = <Panel>
<Header as='h3'>Failed to fetch Zim service.</Header>
<p>You may need to give permission to access the page: <a href={src}>{src}</a></p>
@ -553,7 +498,7 @@ function ZimViewer({src = ZIM_VIEWER_URI, style = null}) {
<p>Check the logs</p>
<pre>journalctl -u wrolpi-kiwix</pre>
</Segment>;
</Panel>;
return <IframeViewer title='zim' src={src} fallback={fallback} style={style}/>
}
@ -573,4 +518,4 @@ export function ZimRoute() {
<Route path='manage' exact element={<ManageZim/>}/>
</Routes>
</div>
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,9 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {MantineProvider} from '@mantine/core';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
import {cssVariablesResolver, mantineTheme} from '../../themes/mantine';
import {ControllerPage} from './ControllerPage';
// Mock the controller API
@ -134,11 +136,13 @@ const defaultSettings = {
const renderControllerPage = () => {
return render(
<BrowserRouter>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
</MantineProvider>
</BrowserRouter>
);
};
@ -279,9 +283,11 @@ describe('DiskSection primary unmount', () => {
expect(screen.getByText(/API service will/)).toBeInTheDocument();
});
// Semantic's Confirm renders confirmButton='Unmount' as the last match.
const buttons = screen.getAllByText('Unmount');
fireEvent.click(buttons[buttons.length - 1]);
// The Confirm modal is portaled to the end of <body>, so it renders
// *before* the page content in DOM order -- scope the query to the
// dialog rather than assuming which match comes last.
const dialog = screen.getByRole('dialog');
fireEvent.click(within(dialog).getByText('Unmount'));
await waitFor(() => {
expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');

View file

@ -27,20 +27,19 @@ import {
WROLModeMessage
} from "../Common";
import {
Button as SButton,
Button,
ButtonGroup,
Checkbox,
Header,
IconButton,
Label,
Loader,
PlaceholderLine,
TableBody,
TableCell,
TableFooter,
TableHeader,
TableHeaderCell,
TableRow
} from "semantic-ui-react";
import {Button, Header, Modal, Placeholder, Progress, Segment, Table} from "../Theme";
Modal,
Panel,
Placeholder,
Progress,
Table,
} from "../ui";
import {useDownloads} from "../../hooks/customHooks";
import {
EditArchiveDownloadForm,
@ -70,35 +69,32 @@ function DownloadProgressModal({progress, url}) {
<Modal.Content>
<Progress
percent={progress.percent}
progress
indicating
color={navColor}
>
{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}
</Progress>
<Table definition>
<TableBody>
<TableRow>
<TableCell width={4}>Speed</TableCell>
<TableCell>{speedText}</TableCell>
</TableRow>
<TableRow>
<TableCell>ETA</TableCell>
<TableCell>{progress.eta || 'Calculating...'}</TableCell>
</TableRow>
<TableRow>
<TableCell>Downloaded</TableCell>
<TableCell>{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}</TableCell>
</TableRow>
{url && <TableRow>
<TableCell>URL</TableCell>
<TableCell style={{wordBreak: 'break-all'}}>{url}</TableCell>
</TableRow>}
</TableBody>
label={`${humanFileSize(progress.bytes_downloaded)} / ${humanFileSize(progress.total_bytes)}`}
/>
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Speed</Table.Cell>
<Table.Cell>{speedText}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>ETA</Table.Cell>
<Table.Cell>{progress.eta || 'Calculating...'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Downloaded</Table.Cell>
<Table.Cell>{humanFileSize(progress.bytes_downloaded)} / {humanFileSize(progress.total_bytes)}</Table.Cell>
</Table.Row>
{url && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>URL</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all'}}>{url}</Table.Cell>
</Table.Row>}
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Close</Button>
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
</>;
@ -154,7 +150,7 @@ function RetryDownloadsButton({callback, selectedIds, clearSelection}) {
const label = hasSelection ? `Retry (${selectedIds.length})` : 'Retry';
return <APIButton
color='green'
role='retry'
onClick={localRetryOnce}
obeyWROLMode={true}
>{label}</APIButton>
@ -184,7 +180,7 @@ function DeleteOnceDownloadsButton({callback, selectedIds, clearSelection}) {
: 'Are you sure you want to delete all downloads? Some may be retried!';
return <APIButton
color='red'
role='danger'
onClick={localDeleteOnce}
confirmContent={confirmContent}
confirmButton='Delete'
@ -231,24 +227,26 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
const errorModal = <Modal
closeIcon
onClose={handleErrorClose}
onOpen={handleErrorOpen}
open={errorModalOpen}
trigger={<Button icon='exclamation circle' color='orange'/>}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
<Modal.Actions>
<SButton onClick={handleEditClose}>Close</SButton>
<Button role='cancel' onClick={handleErrorClose}>Close</Button>
</Modal.Actions>
</Modal>;
const errorTrigger = <IconButton icon='exclamation triangle' label='Download error' color='orange'
onClick={handleErrorOpen}/>;
const hideEdit = downloader === Downloaders.MapCatalog || downloader === Downloaders.MapExtract;
const editButton = hideEdit ? null : <Button icon='edit' onClick={handleEditOpen}/>;
const editButton = hideEdit ? null :
<IconButton icon='edit' label='Edit download' onClick={handleEditOpen}/>;
const restartButton = <APIButton
color='green'
role='retry'
icon='redo'
confirmContent='Are you sure you want to restart this download?'
confirmButton='Restart'
@ -320,24 +318,25 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
</Modal.Content>
</Modal>;
return <TableRow>
<TableCell className='column-ellipsis'>
return <Table.Row failed={!!error && !download.progress}>
<Table.Cell className='column-ellipsis'>
{link(url)}
</TableCell>
<TableCell>{formatFrequency(frequency)}</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>{formatFrequency(frequency)}</Table.Cell>
<Table.Cell>
{last_successful_download && isoDatetimeToElapsedPopup(last_successful_download)}
{status === 'pending' && !download.progress && <Loader active inline size='tiny'/>}
{status === 'pending' && !download.progress && <Loader size='xs'/>}
{download.progress && <DownloadProgressModal progress={download.progress} url={download.url}/>}
</TableCell>
<TableCell>{next}</TableCell>
<TableCell textAlign='right'>
{error && !download.progress && errorModal}
</Table.Cell>
<Table.Cell>{next}</Table.Cell>
<Table.Cell style={{textAlign: 'right'}}>
{error && !download.progress && errorTrigger}
{editButton}
{restartButton}
</TableCell>
</Table.Cell>
{errorModal}
{editModal}
</TableRow>
</Table.Row>
}
function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
@ -384,35 +383,36 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
(text) => <a href={location || url} target='_blank' rel='noopener noreferrer'>{text}</a>;
let completedAtCell = last_successful_download ? isoDatetimeToElapsedPopup(last_successful_download) : null;
let buttonCell = <TableCell/>;
let buttonCell = <Table.Cell/>;
if (status === 'pending' || status === 'new') {
buttonCell = (
<TableCell>
<Table.Cell>
<ButtonGroup>
<APIButton
color='red'
icon='stop circle'
role='danger'
icon='stop'
onClick={handleStop}
confirmContent='Are you sure you want to stop this download? It will not be retried.'
confirmButton='Stop'
obeyWROLMode={true}
/>
{status === 'new' && (downloader === Downloaders.Video || downloader === Downloaders.Archive) && (
<Button
<IconButton
icon='edit'
label='Edit download'
color='blue'
onClick={handleEditOpen}
/>
)}
</ButtonGroup>
</TableCell>
</Table.Cell>
);
} else if (status === 'failed' || status === 'deferred') {
buttonCell = (
<TableCell>
<Table.Cell>
<ButtonGroup>
<APIButton
color='red'
role='danger'
icon='trash'
onClick={handleDelete}
confirmContent='Are you sure you want to delete this download?'
@ -420,14 +420,15 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
obeyWROLMode={true}
/>
{(downloader === Downloaders.Video || downloader === Downloaders.Archive) && status !== 'pending' && (
<Button
<IconButton
icon='edit'
label='Edit download'
color='blue'
onClick={handleEditOpen}
/>
)}
<APIButton
color='green'
role='retry'
icon='redo'
confirmContent='Are you sure you want to restart this download?'
confirmButton='Start'
@ -435,31 +436,37 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
obeyWROLMode={true}
/>
</ButtonGroup>
</TableCell>
</Table.Cell>
);
} else if (status === 'complete' && location) {
buttonCell = (
<TableCell>
<Table.Cell>
{link('View')}
</TableCell>
</Table.Cell>
);
}
if (error && !download.progress) {
completedAtCell = (
<Modal
closeIcon
trigger={<Button icon='exclamation circle' color='red'/>}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
</Modal>
)
}
// Create edit modal for video or archive downloads
let editModal = null;
let errorModal = null;
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
if (error && !download.progress) {
completedAtCell = (
<IconButton icon='exclamation triangle' label='Download error' color='red'
onClick={() => setErrorModalOpen(true)}/>
)
errorModal = <Modal
closeIcon
open={errorModalOpen}
onClose={() => setErrorModalOpen(false)}
>
<Modal.Header>Download Error</Modal.Header>
<Modal.Content>
<pre style={{overflowX: 'scroll'}}>{error}</pre>
</Modal.Content>
</Modal>
}
if (downloader === Downloaders.Video) {
editModal = (
@ -482,7 +489,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
onDelete={handleDelete}
/>
{parentDownloadUrl && (
<p style={{marginTop: '1em', color: '#666'}}>
<p style={{marginTop: '1em', color: 'var(--muted)'}}>
From: <a href={parentDownloadUrl} target='_blank' rel='noopener noreferrer'>
{parentDownloadUrl}
</a>
@ -511,7 +518,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
onDelete={handleDelete}
/>
{parentDownloadUrl && (
<p style={{marginTop: '1em', color: '#666'}}>
<p style={{marginTop: '1em', color: 'var(--muted)'}}>
From: <a href={parentDownloadUrl} target='_blank' rel='noopener noreferrer'>
{parentDownloadUrl}
</a>
@ -522,24 +529,25 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
);
}
return <TableRow>
<TableCell collapsing>
return <Table.Row failed={status === 'failed' || status === 'deferred'}>
<Table.Cell>
<Checkbox
checked={isSelected}
onChange={() => onSelect(id)}
/>
</TableCell>
<TableCell className='column-ellipsis'>
</Table.Cell>
<Table.Cell className='column-ellipsis'>
{link(url)}
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{completedAtCell}
{status === 'pending' && !download.progress ? <Loader active inline size='tiny'/> : null}
{status === 'pending' && !download.progress ? <Loader size='xs'/> : null}
{download.progress && <DownloadProgressModal progress={download.progress} url={download.url}/>}
</TableCell>
</Table.Cell>
{buttonCell}
{errorModal}
{editModal}
</TableRow>
</Table.Row>
}
export function OnceDownloadsTable({downloads, fetchDownloads}) {
@ -597,9 +605,9 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
/>
);
const footer = <TableFooter>
<TableRow>
<TableHeaderCell colSpan={4}>
const footer = <Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={4}>
<ClearDownloadsButton
callback={fetchDownloads}
selectedIds={selectedIds}
@ -615,9 +623,9 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
selectedIds={selectedIds}
clearSelection={clearSelection}
/>
</TableHeaderCell>
</TableRow>
</TableFooter>;
</Table.HeaderCell>
</Table.Row>
</Table.Footer>;
if (downloads && downloads.length >= 1) {
return <SortableTable
@ -629,29 +637,26 @@ export function OnceDownloadsTable({downloads, fetchDownloads}) {
tableProps={{className: 'table-ellipsis'}}
/>
} else if (downloads) {
return <Segment>No downloads are scheduled.</Segment>
return <Panel>No downloads are scheduled.</Panel>
} else if (downloads === undefined) {
return <ErrorMessage>Unable to fetch downloads</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function RecurringDownloadsTable({downloads, fetchDownloads, onDelete}) {
if (downloads && downloads.length >= 1) {
return <Table compact className='table-ellipsis'>
<TableHeader>
<TableRow>
<TableHeaderCell width={8}>URL</TableHeaderCell>
<TableHeaderCell width={2}>Download Frequency</TableHeaderCell>
<TableHeaderCell width={2}>Completed At</TableHeaderCell>
<TableHeaderCell width={1}>Next</TableHeaderCell>
<TableHeaderCell width={3} textAlign='right'>Control</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
return <Table className='table-ellipsis'>
<Table.Header>
<Table.Row>
<Table.HeaderCell style={{width: '50%'}}>URL</Table.HeaderCell>
<Table.HeaderCell style={{width: '12.5%'}}>Download Frequency</Table.HeaderCell>
<Table.HeaderCell style={{width: '12.5%'}}>Completed At</Table.HeaderCell>
<Table.HeaderCell style={{width: '6.25%'}}>Next</Table.HeaderCell>
<Table.HeaderCell style={{width: '18.75%', textAlign: 'right'}}>Control</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{downloads.map(i => {
return <RecurringDownloadRow
key={i.id}
@ -660,17 +665,14 @@ export function RecurringDownloadsTable({downloads, fetchDownloads, onDelete}) {
onDelete={onDelete}
/>
})}
</TableBody>
</Table.Body>
</Table>
} else if (downloads) {
return <Segment>No downloads are scheduled.</Segment>
return <Panel>No downloads are scheduled.</Panel>
} else if (downloads === undefined) {
return <ErrorMessage>Unable to fetch downloads</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={2}/>
}
export function DownloadsPage() {
@ -679,7 +681,7 @@ export function DownloadsPage() {
const {onceDownloads, recurringDownloads, pendingOnceDownloads, fetchDownloads} = useDownloads();
const pendingOnceDownloadsSpan = pendingOnceDownloads > 0 ?
<Label color='violet' size='large'>{pendingOnceDownloads}</Label>
<Label color='violet'>{pendingOnceDownloads}</Label>
: null;
return <>

View file

@ -47,50 +47,39 @@ export function CollectionEditForm({
<form onSubmit={handleSubmit} autoComplete="off">
{/*
* `ui stackable grid` replicates Semantic's own Grid CSS classes (still loaded
* globally via semantic-ui-offline while other call sites remain unmigrated).
* Callers of this component (Channels.js, Archive.js, Playlists.js) still pass
* Semantic <Grid.Row>/<Grid.Column> children -- they have not migrated to the
* component library yet. This div keeps their layout without importing
* semantic-ui-react here. Replace with the real `Grid`/`Grid.Col` once every
* caller has migrated.
*/}
Every caller now passes library components rather than Semantic Grid rows, so
this is a plain flex column instead of the `ui stackable grid` bridge it used
to be. That bridge was the last thing in the app depending on Semantic's
stylesheet.
*/}
<div
className="ui stackable grid"
className='wrolpi-form-rows'
style={form.loading ? {opacity: 0.6, pointerEvents: 'none'} : undefined}
>
{children}
{appliedTagName && <div className="row">
<div className="column">
<SingleTag name={appliedTagName}/>
</div>
</div>}
{appliedTagName && <div><SingleTag name={appliedTagName}/></div>}
<div className="two column row">
<div className="column">
{actionButtons}
{onCancel && <Button
type='button'
role='cancel'
onClick={onCancel}
disabled={form.disabled}
className="action-button-spacing"
>
Cancel
</Button>}
</div>
<div className="column">
<Button
type='submit'
role='save'
size='lg'
style={{float: 'right'}}
disabled={form.disabled}
>
Save
</Button>
</div>
<div style={{display: 'flex', alignItems: 'center', gap: 10}}>
{actionButtons}
{onCancel && <Button
type='button'
role='cancel'
onClick={onCancel}
disabled={form.disabled}
className="action-button-spacing"
>
Cancel
</Button>}
<Button
type='submit'
role='save'
size='lg'
style={{marginLeft: 'auto'}}
disabled={form.disabled}
>
Save
</Button>
</div>
</div>
</form>

View file

@ -32,6 +32,7 @@ export interface ButtonProps extends Omit<MButtonProps, 'leftSection' | 'rightSe
type?: 'button' | 'submit' | 'reset';
href?: string;
target?: string;
component?: any;
}
const renderIcon = (icon?: string | React.ComponentType<any>) => {
@ -69,6 +70,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
const fromRole = role ? roleProps[role] : undefined;
return <MButton
ref={ref}
/*
* An `href` makes this an anchor. Mantine needs `component='a'` to render one, and
* without it the href is accepted and silently dropped — the control looks like a
* link and navigates nowhere. Semantic used `as='a'` for this, so every migrated
* call site that carried `href` alone was quietly broken.
*/
component={props.component ?? (props.href ? 'a' : undefined)}
// An explicit color/variant still wins, so a call site can deviate when it must.
color={props.color ?? fromRole?.color}
variant={props.variant ?? fromRole?.variant}
@ -88,6 +96,9 @@ export interface IconButtonProps extends Omit<ActionIconProps, 'children'> {
label: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
type?: 'button' | 'submit' | 'reset';
href?: string;
target?: string;
component?: any;
}
/** A square, icon-only button. */
@ -97,6 +108,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((
const fromRole = role ? roleProps[role] : undefined;
return <ActionIcon
ref={ref}
// See Button: an href without `component='a'` renders a non-navigating button.
component={props.component ?? (props.href ? 'a' : undefined)}
aria-label={label}
title={label}
color={props.color ?? fromRole?.color}

View file

@ -86,6 +86,11 @@ export function Label({color = 'grey', icon, children, onClick, className}: Labe
export interface ProgressProps {
/** 0-100. Values outside the range are clamped. */
percent?: number;
/**
* Work is happening but its size is unknown — an upload that has not reported yet.
* Animates instead of sitting at 0%, which otherwise reads as stalled.
*/
indeterminate?: boolean;
/** Show the percentage inside the bar. */
showPercent?: boolean;
color?: SemanticColorName;
@ -94,18 +99,26 @@ export interface ProgressProps {
className?: string;
}
export function Progress({percent = 0, showPercent = true, color = 'blue', label, className}: ProgressProps) {
export function Progress({
percent = 0, showPercent = true, color = 'blue', label, indeterminate, className,
}: ProgressProps) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0));
return <div
className={['wrolpi-progress', className].filter(Boolean).join(' ')}
className={['wrolpi-progress', indeterminate ? 'wrolpi-progress-indeterminate' : '', className]
.filter(Boolean).join(' ')}
role='progressbar'
aria-valuenow={Math.round(clamped)}
// An indeterminate bar reports no value, which is what tells assistive tech the
// amount is unknown rather than zero.
aria-valuenow={indeterminate ? undefined : Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className='wrolpi-progress-fill'
style={{width: `${clamped}%`, ['--progress-color' as string]: `var(--${color})`}}
style={{
width: indeterminate ? undefined : `${clamped}%`,
['--progress-color' as string]: `var(--${color})`,
}}
/>
{(showPercent || label) && <span className='wrolpi-progress-text'>
{label ?? `${Math.round(clamped)}%`}

View file

@ -124,10 +124,17 @@ export interface CardProps {
meta?: React.ReactNode;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLDivElement>;
/**
* A token colour name, drawn as an accent along the card's top edge. File cards use
* it to carry the mimetype's colour, so a grid of results is scannable by kind before
* any text is read. It remaps per theme, so night gets a red accent rather than a
* dozen hues.
*/
color?: string;
className?: string;
}
export function Card({media, title, meta, children, onClick, className}: CardProps) {
export function Card({media, title, meta, children, onClick, color, className}: CardProps) {
return <MCard
withBorder
padding={0}
@ -139,6 +146,10 @@ export function Card({media, title, meta, children, onClick, className}: CardPro
cursor: onClick ? 'pointer' : undefined,
display: 'flex',
flexDirection: 'column',
// An accent edge rather than a tinted surface: the design rules keep surfaces
// flat, and a 3px edge survives night mode without becoming a bright patch.
// Along the bottom, where it does not compete with the poster's top edge.
borderBottom: color ? `3px solid var(--${color})` : undefined,
}}
>
{media && <div style={{borderBottom: '1px solid var(--border)'}}>{media}</div>}

View file

@ -342,6 +342,54 @@ html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
border-top: 1px solid var(--border);
}
/* A stack of form rows, replacing Semantic's `ui stackable grid` in edit forms. */
.wrolpi-form-rows {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ------------------------------------------------------------- Form fields */
/*
* `hooks/useForm.js` renders its own <label> and native <input> — it always did, with
* Semantic's Form.Input only wrapping them for field styling. These rules supply that
* styling from tokens instead, so a native input matches the library's own controls.
*/
.wrolpi-form-field input {
width: 100%;
padding: 7px 9px;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
font: inherit;
font-size: 13px;
}
.wrolpi-form-field input::placeholder {
color: var(--muted);
}
.wrolpi-form-field input:focus {
outline: none;
border-color: var(--blue);
}
html[data-theme="night"] .wrolpi-form-field input:focus,
html[data-theme="amber"] .wrolpi-form-field input:focus {
border-color: var(--text);
}
/* An invalid field is dashed, matching every other failure state in the interface. */
.wrolpi-form-field[data-error] input {
border: 1.5px dashed var(--red);
}
.wrolpi-form-field input:disabled {
color: var(--muted);
background: var(--bg);
}
/* ---------------------------------------------------------- Action inputs */
/*
* Input and button share one outline: the input gives up its right border and
@ -499,6 +547,22 @@ html[data-theme="night"] .wrolpi-label {
margin-top: 3px;
}
/*
* Night and amber are single-hue by definition, and a call site may legitimately want
* many distinguishable colours — the Flasher's chip-family badges set `--label-color`
* inline from a twelve-colour palette that no four tokens can express.
*
* In those themes the palette is overridden back to the theme's own text colour, so a
* label cannot put a green or blue pixel on a night-adapted screen. `!important` is
* required because the palette arrives as an inline custom property, which a normal
* rule cannot outrank. The labels stop being distinguishable by colour, which is the
* correct trade: the theme's whole purpose is that no other hue reaches the eye.
*/
html[data-theme="night"] .wrolpi-label,
html[data-theme="amber"] .wrolpi-label {
--label-color: var(--text) !important;
}
/* --------------------------------------------------------------- Messages */
.wrolpi-message {
@ -581,6 +645,34 @@ html[data-theme="night"] .wrolpi-label {
}
}
/*
* Indeterminate: a sliding band, for work whose size is not known yet. Semantic called
* this `indicating`; without it an upload that has not reported its size sits at 0% and
* reads as stalled.
*/
.wrolpi-progress-indeterminate .wrolpi-progress-fill {
width: 35%;
animation: wrolpi-progress-slide 1.4s ease-in-out infinite;
}
@keyframes wrolpi-progress-slide {
from {
margin-left: -35%;
}
to {
margin-left: 100%;
}
}
/* Motion is the only signal here, so when it is suppressed the bar fills instead. */
@media (prefers-reduced-motion: reduce) {
.wrolpi-progress-indeterminate .wrolpi-progress-fill {
animation: none;
width: 100%;
opacity: 0.5;
}
}
.wrolpi-progress-text {
position: absolute;
inset: 0;

View file

@ -11,6 +11,7 @@ import {themeChoices} from '../../themes/names';
import {
ActionInput,
Button,
Card,
Confirm,
Header,
Icon,
@ -22,6 +23,7 @@ import {
IconButton,
Label,
Message,
Modal,
Panel,
Progress,
resolveIconName,
@ -152,6 +154,58 @@ describe('Button', () => {
expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('data-size', 'sm');
});
it('renders an anchor when given an href, without being told twice', () => {
/*
* Mantine drops `href` unless it also gets `component='a'`, so a button carrying
* only an href looked like a link and navigated nowhere. Semantic spelled this
* `as='a'`, so every migrated call site that kept just the href was silently
* broken — a download button that downloads nothing.
*/
renderUI(<>
<Button href='/media/thing.pdf'>Download</Button>
<IconButton icon='download' label='Save file' href='/media/other.pdf'/>
</>);
const link = screen.getByRole('link', {name: 'Download'});
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', '/media/thing.pdf');
expect(screen.getByRole('link', {name: 'Save file'}).tagName).toBe('A');
});
it('forwards a ref to the underlying DOM element', () => {
/*
* Ported from the old Theme.test.js, which caught a real crash: Semantic's Button
* was a class component, so a forwarded ref resolved to the class instance, and
* anything that called `node.contains(...)` on the trigger -- a Modal or Popup
* portal checking whether a click landed inside -- threw "contains is not a
* function". Our Tooltip and Menu targets rely on this ref too.
*/
const ref = React.createRef();
renderUI(<Button ref={ref}>Click</Button>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('survives a document click while used as a modal trigger', () => {
// The other half of that regression: the Control page crashed when the document
// was clicked with a modal open whose trigger was a themed Button.
renderUI(<>
<Button>Open</Button>
<Modal open onClose={jest.fn()}><Modal.Content>content</Modal.Content></Modal>
</>);
expect(() => document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})))
.not.toThrow();
});
it('is still a button when there is no href', () => {
renderUI(<Button onClick={jest.fn()}>Save</Button>);
expect(screen.getByRole('button', {name: 'Save'}).tagName).toBe('BUTTON');
});
it('gives icon-only buttons an accessible name', () => {
renderUI(<IconButton icon='trash' label='Delete channel'/>);
@ -255,6 +309,20 @@ describe('Progress', () => {
expect(bar).toHaveTextContent('62%');
});
it('keeps its percent text readable on every bar colour', () => {
/*
* Ported from Theme.test.js, which asserted Semantic's `inverted-progress-text`
* class across all fourteen colours and both modes. That class is gone; the
* mechanism now is that the text always takes `--text` while light mode lightens
* the fill beneath it, because the text sits across both the filled and unfilled
* halves of the bar.
*/
const css = fs.readFileSync(path.join(__dirname, 'ui.css'), 'utf8');
expect(css).toMatch(/\.wrolpi-progress-text\s*{[^}]*color:\s*var\(--text\)/);
expect(css).toMatch(/html\[data-theme="light"] \.wrolpi-progress-fill\s*{[^}]*brightness/);
});
it('clamps values outside 0-100', () => {
// A download reporting 103% must not overflow its container.
renderUI(<><Progress percent={150}/><Progress percent={-20}/></>);
@ -270,6 +338,17 @@ describe('Progress', () => {
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
});
it('reports no value when the size is unknown', () => {
// Semantic called this `indicating`. An upload that has not reported its size
// would otherwise sit at 0% and read as stalled, and `aria-valuenow=0` would tell
// a screen reader the same wrong thing.
renderUI(<Progress indeterminate label='Uploading…'/>);
const bar = screen.getByRole('progressbar');
expect(bar).not.toHaveAttribute('aria-valuenow');
expect(bar).toHaveClass('wrolpi-progress-indeterminate');
});
it('shows arbitrary label text instead of the percentage', () => {
renderUI(<Progress percent={40} label='2.1 GB / 5.3 GB'/>);
@ -354,6 +433,23 @@ describe('Table', () => {
});
});
describe('Card', () => {
it('draws a mimetype accent from a token, not a hex', () => {
// File cards carry the mimetype's colour on their top edge so a grid of results is
// scannable by kind. Semantic did this with `<Card color='violet'>`; two migrated
// files had dropped the accent because our Card had no equivalent.
const {container} = renderUI(<Card title='Water Storage.pdf' color='red'/>);
expect(container.firstChild).toHaveStyle({borderBottom: '3px solid var(--red)'});
});
it('has no accent when no colour is given', () => {
const {container} = renderUI(<Card title='Plain'/>);
expect(container.firstChild.style.borderBottom).toBe('');
});
});
describe('Panel', () => {
it('marks danger zones', () => {
const {container} = renderUI(<Panel danger>Destructive actions</Panel>);

View file

@ -1,8 +1,7 @@
import React from "react";
import _ from "lodash";
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
import {Form, Icon, TextArea} from "../components/Theme";
import {Message} from "../components/ui";
import {Icon, Message, Textarea} from "../components/ui";
async function asyncNoOp() {
@ -367,13 +366,14 @@ export function InputForm({
/>
}
</label>
<Form.Input
id={`${name}_input`}
placeholder={placeholder}
error={inputProps.error}
>
<input {...extraInputProps} {...inputProps}/>
</Form.Input>
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
<input
id={`${name}_input`}
placeholder={placeholder}
{...extraInputProps}
{...inputProps}
/>
</div>
{messageElm}
</>
}
@ -463,31 +463,36 @@ export function UrlsTextarea({name = 'urls', required, form}) {
};
return (
<Form.Input required error={inputProps.error} label="URLs">
<TextArea
id="urls_textarea"
placeholder="Enter one URL per line"
name="urls"
onDrop={handleDrop}
onKeyDown={handleKeyDown}
{...inputProps}
/>
</Form.Input>
<Textarea
id="urls_textarea"
label="URLs"
required
error={inputProps.error}
placeholder="Enter one URL per line"
name="urls"
onDrop={handleDrop}
onKeyDown={handleKeyDown}
autosize
minRows={3}
{...inputProps}
/>
);
}
export function ToggleForm({form, name, label, path, icon = null, iconSize = 'big', disabled = false}) {
const [inputProps, inputAttrs] = form.getCustomProps({name, path})
const iconElm = icon ? <Icon size={iconSize} name={icon}/> : null;
return <Form.Input
label={label}>
{iconElm}
<Toggle
disabled={disabled || form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</Form.Input>
const iconElm = icon ? <Icon size={iconSize === 'big' ? 'large' : iconSize} name={icon}/> : null;
return <div className='wrolpi-form-field'>
<label><b>{label}</b></label>
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
{iconElm}
<Toggle
disabled={disabled || form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</div>
</div>
}

View file

@ -1,8 +1,6 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
/* The font comes from the theme's `--font-body` role token; see themes/tokens.css. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -1,17 +1 @@
[
"src/components/Archive.js",
"src/components/BulkTagModal.js",
"src/components/Channels.js",
"src/components/Docs.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
"src/components/Flasher.js",
"src/components/Nav.js",
"src/components/Playlists.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
"src/components/VideoPlayer.js",
"src/components/Zim.js",
"src/components/admin/ControllerPage.js",
"src/components/admin/Downloads.js"
]
[]

View file

@ -13,7 +13,10 @@ import baseline from './semantic-baseline.json';
* print 75 warnings on every dev rebuild, or fail the build outright, and CI
* already unsets CI= for the build step so warnings there guard nothing.
*
* When the list reaches zero, delete this test, the JSON, and the dependencies.
* The list has reached zero. The test stays as a guard rather than being deleted: it now
* asserts that nothing imports Semantic UI, nothing calls its toast library, nothing leans
* on its stylesheet, and no migrated file reaches back through Theme.tsx. It can go when
* the packages themselves are uninstalled.
*/
const SRC = path.join(__dirname);
@ -74,7 +77,9 @@ describe('Semantic UI removal', () => {
* collapse. Anything on this list is a deliberate bridge that must be revisited
* then; anything NOT on it is an accident.
*/
const allowed = ['src/components/collections/CollectionEditForm.js'];
// Empty, and it should stay that way: Semantic's stylesheet is no longer loaded,
// so a `ui ...` class name now styles nothing at all.
const allowed = [];
const bridges = sourceFiles()
.filter(file => !baseline.includes(file))

View file

@ -158,12 +158,61 @@ html[data-theme="amber"] {
/* Shell */
/* ------------------------------------------------------------------------- */
/*
* Base element styling.
*
* semantic.min.css used to supply all of this, and removing it took the lot with it --
* links lost their colour and gained the browser's underline and visited purple, and
* every padded element silently changed size when `box-sizing: border-box` went away.
* None of it belonged to Semantic; it belongs here, expressed in tokens.
*/
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
}
/*
* Links take the primary token rather than the browser's blue, and carry no underline
* until hovered -- a great many of them wrap cards, posters, statistics and icons, where
* a permanent underline is noise. `--blue` remaps per theme, so night gets a dim red
* link and amber an amber one without another rule.
*/
a {
color: var(--blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* A link wrapping a card, poster or icon carries no text of its own to underline. */
a:has(img, svg, .wrolpi-card-icon):hover {
text-decoration: none;
}
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
html[data-theme="night"] a:focus-visible,
html[data-theme="amber"] a:focus-visible {
outline-color: var(--text);
}
/* ------------------------------------------------------------------------- */
/* Media filtering */
/* ------------------------------------------------------------------------- */
@ -204,6 +253,19 @@ html[data-media-filter="amber-mono"] :is(img, video, canvas, iframe, embed, obje
filter: url(#wrolpi-amber-mono);
}
/*
* A `.media` wrapper filters its whole subtree as one unit, so the leaf media inside
* it must not be filtered a second time. Two passes of a luminance-to-red matrix is
* not the same as one: the second pass takes the luminance of an already-red pixel
* (0.2126 of it) and the image comes out markedly darker than everything around it.
*
* The audio player's poster is the case that found this — the poster sits under
* custom control glyphs, so the wrapper is filtered to keep the two tinted together.
*/
html[data-media-filter] .media :is(img, video, canvas, iframe, embed, object) {
filter: none;
}
/*
* A filter on an ancestor stops applying once the browser promotes a video to
* native fullscreen, so the filter is restated on the fullscreened element.

View file

@ -103,7 +103,9 @@ describe('media filter rules', () => {
.map(match => match[1]);
expect(filterRules.length).toBeGreaterThan(0);
filterRules.forEach(selector => expect(selector).toMatch(/^data-media-filter=/));
// With or without a value: the reset rule for nested media keys on the bare
// attribute. What matters is that no filter rule keys on `data-theme`.
filterRules.forEach(selector => expect(selector).toMatch(/^data-media-filter/));
});
it('has a rule for every filter a theme offers', () => {
@ -117,6 +119,14 @@ describe('media filter rules', () => {
});
});
it('does not filter leaf media twice inside a filtered wrapper', () => {
// A `.media` wrapper filters its subtree as a unit; the img/video inside it also
// matches the element-type rule, and two passes of the matrix take the luminance
// of an already-red pixel, leaving that image much darker than its surroundings.
expect(tokensCss).toMatch(
/html\[data-media-filter] \.media :is\([^)]*\)\s*{\s*filter:\s*none/);
});
it('never filters an ancestor, only leaf media', () => {
// A CSS filter creates a containing block, which would break `position: fixed`
// descendants — the nav bar and every modal.

View file

@ -31,14 +31,6 @@ export interface StyleProps {
// ThemeContext value type
export interface ThemeContextValue {
/** Props for Semantic elements that support "inverted" */
i: InvertedProps;
/** Props to invert styles on elements */
s: StyleProps;
/** Props to invert text color */
t: StyleProps;
/** Class name string: 'inverted' or '' */
inverted: string;
/** Current applied theme */
theme: ThemeName;
/** User's saved theme preference */