mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
ui: Phase 2 complete — Semantic UI is gone (#588)
ui: Phase 2 complete — Semantic UI is gone
This commit is contained in:
commit
3a8b73fd3e
39 changed files with 2741 additions and 3357 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 ? <> 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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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 — from your computer or from your WROLPi's media directory —
|
||||
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 — from your computer or from your WROLPi's media
|
||||
directory — 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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} <SIcon name='lock'/></> : name;
|
||||
const {i} = React.useContext(ThemeContext);
|
||||
const topNavText = wrolModeEnabled ? <>{name} <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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 <>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}%`}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue