ui: thirty-five files off Semantic, leaving fifteen

Phase 2, part 4.  Baseline 50 -> 15.

Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.

Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message.  useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.

Two library gaps closed, both found by call sites needing them:

  ActionInput is a forwardRef now.  Its ref reaches the input rather than the
  wrapper, because callers use it to focus or select the field -- a ref to the
  surrounding div would do neither, which is why one call site had resorted to
  hand-copying the component's markup.

  Header takes a `color` token name.  Three separate call sites had reached for
  an inline style to colour a heading that carries meaning ("3 items to add"
  against "2 to remove"), which is how a hex ends up in markup.

Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used.  Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.

Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads.  Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
This commit is contained in:
Roland 2026-07-30 12:21:30 -06:00
parent 87c9bff8ed
commit e8dd804010
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
48 changed files with 3273 additions and 3877 deletions

View file

@ -4,7 +4,7 @@ import {NavBar} from "./components/Nav";
import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, RouterProvider} from "react-router";
import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos";
import AdminRoute from "./components/admin/AdminRoute";
import {Container} from "semantic-ui-react";
import {Box, Header} from "./components/ui";
import 'semantic-ui-offline/semantic.min.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
@ -20,13 +20,12 @@ import {QueryProvider, StatusProvider} from "./hooks/customHooks";
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
import {MapRoute} from "./components/Map";
import {MediaContextProvider, mediaStyles, StatusContext, ThemeContext} from "./contexts/contexts";
import {Header, ThemeProvider} from "./components/Theme";
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
import {ThemeProvider} from "./components/Theme";
import {DashboardPage} from "./DashboardPage";
import {DonatePage} from "./components/DonatePage";
import {ThemeSamplePage} from "./components/ThemeSamplePage";
import {useEventsInterval} from "./Events";
import {SemanticToastContainer} from "react-semantic-toasts-2";
import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
@ -37,11 +36,10 @@ import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
function PageNotFound() {
const {t} = useContext(ThemeContext);
return <Container fluid>
return <Box>
<Header as='h1'>Page Not Found!</Header>
<p {...t}>The page you requested cannot be found</p>
</Container>
<p>The page you requested cannot be found</p>
</Box>
}
function Dot() {
@ -49,7 +47,6 @@ function Dot() {
}
function Footer() {
const {t} = useContext(ThemeContext);
const {status} = useContext(StatusContext);
let version;
try {
@ -57,13 +54,13 @@ function Footer() {
} catch (e) {
// Not logging because this is not that important.
}
return <Container textAlign='center' style={{marginTop: '1.5em', marginBottom: '1em', ...t}}>
<span {...t}>
return <Box style={{textAlign: 'center', marginTop: '1.5em', marginBottom: '1em'}}>
<span>
WROLPi {version} <Dot/>
<a href='https://wrolpi.org' target='_blank' rel='nofollow noreferrer'>WROLPi.org</a> <Dot/>
<Link to='/donate'>Donate</Link>
</span>
</Container>
</Box>
}
function Root() {
@ -129,8 +126,7 @@ export default function App() {
<FileWorkerStatusProvider>
{/* Context and style to handle switching between mobile/computer. */}
<style>{mediaStyles}</style>
{/* Toasts can be on any page. */}
<SemanticToastContainer position='top-right'/>
{/* Toasts (from components/ui) are mounted by ThemeProvider, which wraps every page. */}
<MediaContextProvider>
<RouterProvider router={router}/>
</MediaContextProvider>

View file

@ -12,19 +12,22 @@ import {useHotkeys} from "react-hotkeys-hook";
import {Media, SettingsContext, StatusContext} from "./contexts/contexts";
import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
Divider,
Form,
Group,
Header,
Icon,
Message,
Modal,
Segment,
Statistic
} from "./components/Theme";
Panel,
Stack,
Statistic,
StatisticGroup,
} from "./components/ui";
import {Link, useNavigate, useSearchParams} from "react-router";
import {BandwidthProgressCombined, CPUUsageProgress} from "./components/admin/Status";
import {ProgressPlaceholder} from "./components/Placeholder";
import {GridColumn, GridRow, Icon, Input, Message} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {refreshFiles} from "./api";
import _ from "lodash";
import {TagsDashboard} from "./Tags";
@ -37,6 +40,9 @@ import {fileMimetypeFilterOptions, FileCards, SearchFilterButton} from "./compon
import {useCalculators} from "./components/Calculators";
import {classifyAndEvaluate} from "./components/calculators/mathConfig";
// A spinning refresh glyph, used as the `icon` for the "files are being refreshed" Message.
const RefreshingIcon = (props) => <Icon name='circle notched' loading {...props}/>;
export function FlagsMessages() {
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {status} = useContext(StatusContext);
@ -53,47 +59,44 @@ export function FlagsMessages() {
// Do not tell the maintainer to refresh the files if the FileWorker is busy.
if (flags.file_worker_busy) {
// FileWorker is busy.
refreshingMessage = <Message icon>
<Icon name='circle notched' loading/>
<Message.Content>
<Message.Header>Your files are being refreshed.</Message.Header>
<p><Link to='/files'>Click here to view the progress</Link></p>
</Message.Content>
refreshingMessage = <Message kind='info' icon={RefreshingIcon} title='Your files are being refreshed.'>
<p><Link to='/files'>Click here to view the progress</Link></p>
</Message>;
} else if (!flags.refresh_complete) {
// `refresh_complete` flag is not set. Tell the maintainer to refresh the files.
refreshRequiredMessage = <Message icon warning onClick={refreshFiles}>
<Icon name='hand point right'/>
<Message.Content>
<Message.Header>Refresh required</Message.Header>
<a href='#'>Click here</a> to refresh all your files.
</Message.Content>
refreshRequiredMessage = <Message kind='warning' icon='hand point right' title='Refresh required'>
<a href='#' onClick={(e) => {
e.preventDefault();
refreshFiles();
}}>Click here</a> to refresh all your files.
</Message>;
}
let dbDownMessage;
if (!flags.db_up) {
dbDownMessage = <ErrorMessage>
<Message.Header>Database is down</Message.Header>
API is unable to connect to the database. Check the server logs.
<strong>Database is down</strong>
<p>API is unable to connect to the database. Check the server logs.</p>
</ErrorMessage>
}
let internetDownMessage;
if (!flags.have_internet && !settings.wrol_mode) {
internetDownMessage = <ErrorMessage icon='globe'>
<Message.Header>No Internet</Message.Header>
WROLPi has no Internet. Downloads will not start.
<strong>No Internet</strong>
<p>WROLPi has no Internet. Downloads will not start.</p>
</ErrorMessage>;
}
let mediaUnmountedMessage;
if (flags.media_mounted === false) {
mediaUnmountedMessage = <ErrorMessage icon='hdd outline'>
<Message.Header>No drive mounted</Message.Header>
WROLPi has no media drive mounted for one or more storage locations.
Downloads would write to the root filesystem and can fill it.
{' '}<Link to='/admin/controller'>Open the Controller</Link> to mount a drive.
mediaUnmountedMessage = <ErrorMessage icon='hdd'>
<strong>No drive mounted</strong>
<p>
WROLPi has no media drive mounted for one or more storage locations.
Downloads would write to the root filesystem and can fill it.
{' '}<Link to='/admin/controller'>Open the Controller</Link> to mount a drive.
</p>
</ErrorMessage>;
}
@ -140,7 +143,6 @@ function EvaluateForm() {
<pre>2 Tb to Gb</pre>
</span>
const inputRef = React.useRef();
const [showMessage, setShowMessage] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [evaluatedValue, setEvaluatedValue] = React.useState(helpContents);
@ -166,45 +168,36 @@ function EvaluateForm() {
}
}
return <Form onSubmit={doEvaluate}>
<Input fluid
ref={inputRef}
value={inputValue}
label='𝒇'
type='text'
onFocus={() => setShowMessage(true)}
onChange={(e, {value}) => setInputValue(value)}
action={<Button type='submit'>Evaluate</Button>}
return <form onSubmit={(e) => {
e.preventDefault();
doEvaluate();
}}>
<ActionInput
value={inputValue}
leftSection='𝒇'
type='text'
onFocus={() => setShowMessage(true)}
onChange={(e) => setInputValue(e.currentTarget.value)}
action={<Button type='submit' role='primary'>Evaluate</Button>}
/>
{showMessage &&
<Message>
<Message.Header>Result</Message.Header>
<Message.Content>
{evaluatedValue}
</Message.Content>
<Message kind='info' title='Result'>
{evaluatedValue}
</Message>
}
</Form>
</form>
}
function DashboardCalculators() {
const {calculatorLinks} = useCalculators();
return <Segment>
return <Panel>
<Header as='h2'>Calculators</Header>
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<EvaluateForm/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
{calculatorLinks}
</Grid.Column>
</Grid.Row>
</Grid>
</Segment>
<Stack>
<EvaluateForm/>
<div>{calculatorLinks}</div>
</Stack>
</Panel>
}
export function Getters() {
@ -244,25 +237,17 @@ export function Getters() {
}
}
const getter = <Segment>
<Grid columns={2} textAlign='center'>
<Divider vertical>Or</Divider>
<GridRow verticalAlign='middle'>
<GridColumn>
<Button color='violet' onClick={e => handleSetGetter(e, 'downloads')}>
<Icon name='download'/>
Download
</Button>
</GridColumn>
<GridColumn>
<Button color='green' onClick={e => handleSetGetter(e, 'upload')}>
<Icon name='upload'/>
Upload
</Button>
</GridColumn>
</GridRow>
</Grid>
</Segment>;
const getter = <Panel>
<Group justify='center' align='stretch' gap='xl' wrap='wrap'>
<Button role='primary' icon='download' onClick={e => handleSetGetter(e, 'downloads')}>
Download
</Button>
<Divider orientation='vertical' label='Or' labelPosition='center'/>
<Button role='save' icon='upload' onClick={e => handleSetGetter(e, 'upload')}>
Upload
</Button>
</Group>
</Panel>;
let getterModal;
if (selectedGetter === 'downloads') {
@ -330,16 +315,16 @@ function DashboardStatus() {
bandwidths = null;
}
return <Segment>
return <Panel>
<Link to='/admin/status'>
<Header as='h2'>Status</Header>
<CPUUsageProgress value={percent} label='CPU Usage'/>
<Statistic.Group size='mini'>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={load['minute_1']} cores={cores}/>
<LoadStatistic label='5 Min. Load' value={load['minute_5']} cores={cores}/>
<LoadStatistic label='15 Min. Load' value={load['minute_15']} cores={cores}/>
</Statistic.Group>
</StatisticGroup>
<Header as='h3'>Bandwidth</Header>
{bandwidths}
@ -348,25 +333,25 @@ function DashboardStatus() {
<Divider style={{marginTop: '3em'}}/>
<Link to='/admin'>
<Statistic.Group size='mini'>
<StatisticGroup>
<Statistic label='Downloading' value={pending_downloads}/>
</Statistic.Group>
</StatisticGroup>
</Link>
</Segment>;
</Panel>;
}
function DashboardRecentFiles() {
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
return <Segment>
return <Panel>
<RefreshHeader
header='Recently Viewed Files'
popupContents='Fetch the most recent files again'
onRefresh={fetchFiles}
/>
<FileCards files={searchFiles} loading={loading}/>
</Segment>
</Panel>
}
export function DashboardPage() {

View file

@ -1,8 +1,7 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router';
import {render, screen} from './test-utils';
import {Getters, FlagsMessages} from './DashboardPage';
import {SettingsContext, StatusContext, ThemeContext} from './contexts/contexts';
import {SettingsContext, StatusContext} from './contexts/contexts';
// Replace DownloadMenu with a stub that records its props. The deep-link
// behavior under test is just "Getters reads URL params and forwards them
@ -23,18 +22,14 @@ jest.mock('./hooks/customHooks', () => ({
}));
function renderAtUrl(url) {
// test-utils' render() wraps content in a BrowserRouter (not MemoryRouter),
// so point the jsdom location at the desired URL before rendering.
window.history.pushState({}, '', url);
const status = {flags: {refresh_complete: true}};
const theme = {
i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {},
};
return render(
<MemoryRouter initialEntries={[url]}>
<ThemeContext.Provider value={theme}>
<StatusContext.Provider value={{status}}>
<Getters/>
</StatusContext.Provider>
</ThemeContext.Provider>
</MemoryRouter>
<StatusContext.Provider value={{status}}>
<Getters/>
</StatusContext.Provider>
);
}
@ -80,13 +75,11 @@ describe('DashboardPage Getters deep-link', () => {
function renderFlagsMessages({flags = {}, settings = {}} = {}) {
const status = {flags: {refresh_complete: true, db_up: true, have_internet: true, ...flags}};
return render(
<MemoryRouter>
<SettingsContext.Provider value={{settings, fetchSettings: () => {}}}>
<StatusContext.Provider value={{status}}>
<FlagsMessages/>
</StatusContext.Provider>
</SettingsContext.Provider>
</MemoryRouter>
<SettingsContext.Provider value={{settings, fetchSettings: () => {}}}>
<StatusContext.Provider value={{status}}>
<FlagsMessages/>
</StatusContext.Provider>
</SettingsContext.Provider>
);
}

View file

@ -1,7 +1,7 @@
import React, {useEffect} from 'react';
import {useRecurringTimeout} from "./hooks/customHooks";
import {ApiDownError, getEvents} from "./api";
import {toast} from "react-semantic-toasts-2";
import {toast} from "./components/ui";
const apiEventName = 'apiEvent';

View file

@ -1,6 +1,5 @@
import React, {useEffect} from "react";
import {ApiDownError, deleteTag, getRecentTags, getTags, saveTag} from "./api";
import {Dimmer, Divider, Grid, GridColumn, GridRow, Input, Label, TableCell, TableRow,} from "semantic-ui-react";
import {
APIButton,
contrastingColor,
@ -10,17 +9,24 @@ import {
scrollToTopOfElement
} from "./components/Common";
import {
Box,
Button,
Form,
Divider,
Group,
Header,
Loader,
Icon,
IconButton,
Label,
Loading,
Modal,
Segment
} from "./components/Theme";
Panel,
Table,
TextInput,
} from "./components/ui";
import _ from "lodash";
import {HexColorPicker} from "react-colorful";
import {useRecurringTimeout} from "./hooks/customHooks";
import {Media, QueryContext, ThemeContext} from "./contexts/contexts";
import {Media, QueryContext} from "./contexts/contexts";
import {Link, useNavigate} from "react-router";
import {TagPlaceholder} from "./components/Placeholder";
import {SortableTable} from "./components/SortableTable";
@ -96,33 +102,36 @@ export function useTags() {
|| fuzzyMatch(lowerName, i.name.toLowerCase(), 2))
}
// A single tag chip. Tag colors are chosen by the user (stored per-tag), not by the
// theme, so they are rendered with the raw hex value rather than a token -- but the
// `wrolpi-label` className still gives them the theme's shape (and its night-mode
// outline treatment, which keys off the same `--label-color` variable).
const NameToTagLabel = ({name, to, ...props}) => {
const tag = findTagByName(name);
if (tag !== null && tag !== undefined) {
const tagColor = tag['color'] || DEFAULT_TAG_COLOR;
const textColor = contrastingColor(tagColor);
const style = {...props['style'], backgroundColor: tagColor, color: textColor};
return <Label
size='large'
const className = ['wrolpi-label', props.onClick ? 'clickable' : ''].filter(Boolean).join(' ');
return <span
{...props} // onClick passed here.
style={style}
className={props.onClick ? 'clickable' : null}
className={className}
style={{...props['style'], ['--label-color']: tagColor, color: textColor}}
>
{name}
</Label>;
</span>;
}
// No tags have been fetched.
return <Label size='large'>{name}</Label>;
return <Label>{name}</Label>;
}
const TagsGroup = ({tagNames, onClick}) => {
if (!tagNames || tagNames.length === 0) {
return <React.Fragment/>;
}
return <Label.Group tag>
return <Group gap={6}>
{tagNames.map(i => <NameToTagLabel key={i} name={i} onClick={() => onClick(i)}/>)}
</Label.Group>
</Group>
}
const TagLabelLink = ({name, props}) => {
@ -147,13 +156,13 @@ export function useTags() {
return <React.Fragment/>;
}
return <Label.Group tag>
return <Group gap={6}>
{tagNames.map(i => <TagLabelLink key={i} name={i} props={props}/>)}
</Label.Group>
</Group>
}
const SingleTag = ({name, ...props}) => {
return <Label.Group tag {...props}><NameToTagLabel name={name}/></Label.Group>
return <Group gap={6} {...props}><NameToTagLabel name={name}/></Group>
}
useEffect(() => {
@ -187,16 +196,15 @@ function EditTagRow({tag, onDelete, onEdit}) {
const {SingleTag} = React.useContext(TagsContext);
const {name, color, id, file_group_count, zim_entry_count, channel_count, domain_count} = tag;
const deleteConfirm = <>
<APIButton
icon='trash'
color='red'
confirmContent={`Are you sure you want to delete: ${name}?`}
confirmButton='Delete'
onClick={async () => onDelete(id, name)}
/>
</>;
const editButton = <Button primary onClick={() => onEdit(name, color, id)} icon='edit'/>;
const deleteConfirm = <APIButton
icon='trash'
role='danger'
confirmContent={`Are you sure you want to delete: ${name}?`}
confirmButton='Delete'
onClick={async () => onDelete(id, name)}
/>;
const editButton = <IconButton icon='edit' label='Edit' role='primary'
onClick={() => onEdit(name, color, id)}/>;
// Individual count labels for tablet+
const fileCountColor = file_group_count > 0 ? 'black' : 'grey';
@ -213,32 +221,31 @@ function EditTagRow({tag, onDelete, onEdit}) {
const totalCountColor = totalCount > 0 ? 'black' : 'grey';
const totalCountLabel = <Label color={totalCountColor}>{totalCount}</Label>;
return <TableRow>
<TableCell>{deleteConfirm}</TableCell>
<TableCell>{editButton}</TableCell>
<TableCell><SingleTag name={name}/></TableCell>
return <Table.Row>
<Table.Cell>{deleteConfirm}</Table.Cell>
<Table.Cell>{editButton}</Table.Cell>
<Table.Cell><SingleTag name={name}/></Table.Cell>
<Media at='mobile'>
{(className, renderChildren) => {
return renderChildren ? <TableCell className={className}>{totalCountLabel}</TableCell> : null;
return renderChildren ? <Table.Cell className={className}>{totalCountLabel}</Table.Cell> : null;
}}
</Media>
<Media greaterThanOrEqual='tablet'>
{(className, renderChildren) => {
return renderChildren ? <>
<TableCell className={className}>{fileCountLabel}</TableCell>
<TableCell className={className}>{zimCountLabel}</TableCell>
<TableCell className={className}>{channelCountLabel}</TableCell>
<TableCell className={className}>{domainCountLabel}</TableCell>
<Table.Cell className={className}>{fileCountLabel}</Table.Cell>
<Table.Cell className={className}>{zimCountLabel}</Table.Cell>
<Table.Cell className={className}>{channelCountLabel}</Table.Cell>
<Table.Cell className={className}>{domainCountLabel}</Table.Cell>
</>
: null;
}}
</Media>
</TableRow>
</Table.Row>
}
function EditTagsModal() {
const {fetchTags, tags} = React.useContext(TagsContext);
const {inverted} = React.useContext(ThemeContext);
// Return a random, but distinct Hex color.
const getRandomColor = () => getDistinctColor((tags || []).map(i => i.color));
@ -292,11 +299,12 @@ function EditTagsModal() {
setRandomColor();
}
const handleTagNameChange = (e, {value}) => {
const handleTagNameChange = (e) => {
const value = e.target.value;
setTagName(value);
// Tag names cannot contain these characters.
const tagNameRegex = /[,<>:|"\\?*%!\n\r]/;
setTagNameError(tagNameRegex.test(value) ? {content: 'Invalid Tag Name'} : null);
setTagNameError(tagNameRegex.test(value) ? 'Invalid Tag Name' : null);
}
const tableHeaders = [
@ -316,59 +324,41 @@ function EditTagsModal() {
];
return <>
<Modal closeIcon
open={open}
onOpen={() => setOpen(true)}
onClose={localOnClose}
>
<Modal open={open} onClose={localOnClose}>
<Modal.Header>Edit Tags</Modal.Header>
<div className={`content scrolling ${inverted}`} id='editModalContent'>
<Label.Group tag>
<Label size='large' style={{backgroundColor: tagColor, color: textColor}}>
<div id='editModalContent'>
<Group gap={6}>
<span
className='wrolpi-label'
style={{['--label-color']: tagColor, color: textColor}}
>
{tagName || 'Example Tag'}
</Label>
</Label.Group>
</span>
</Group>
<Form autoComplete='off'>
<Form.Input required
autoFocus
label={<b>Tag Name</b>}
placeholder='Unique name'
value={tagName}
error={tagNameError}
onChange={handleTagNameChange}
/>
</Form>
<TextInput
required
autoFocus
autoComplete='off'
label={<b>Tag Name</b>}
placeholder='Unique name'
value={tagName}
error={tagNameError}
onChange={handleTagNameChange}
style={{marginTop: '1em'}}
/>
<HexColorPicker color={tagColor} onChange={setTagColor} style={{marginTop: '1em'}}/>
<Grid>
<Grid.Row columns={2}>
<Grid.Column>
<Button
color='orange'
onClick={setRandomColor}
style={{marginTop: '2em'}}
type='button'
>Random</Button>
</Grid.Column>
<Grid.Column textAlign='right'>
<APIButton
color='violet'
size='big'
onClick={localSaveTag}
style={{marginTop: '2em'}}
disabled={disabled}
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</Grid>
<Group justify='space-between' style={{marginTop: '2em'}}>
<Button role='cancel' type='button' onClick={setRandomColor}>Random</Button>
<APIButton role='save' onClick={localSaveTag} disabled={disabled}>Save</APIButton>
</Group>
<Divider/>
<Media at='mobile'>
<SortableTable
tableProps={{unstackable: true}}
data={tags}
rowFunc={(i, sortData) => <EditTagRow key={i['name']} tag={i} onDelete={localDeleteTag}
onEdit={localEditTag}/>}
@ -378,7 +368,6 @@ function EditTagsModal() {
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{unstackable: true}}
data={tags}
rowFunc={(i, sortData) => <EditTagRow key={i['name']} tag={i} onDelete={localDeleteTag}
onEdit={localEditTag}/>}
@ -388,7 +377,7 @@ function EditTagsModal() {
</Media>
</div>
</Modal>
<Button onClick={() => setOpen(true)} color='violet' disabled={tags === undefined}>
<Button role='primary' onClick={() => setOpen(true)} disabled={tags === undefined}>
Edit
</Button>
</>
@ -406,7 +395,6 @@ export function AddTagsButton({
limit = null,
disabled = false,
filterByOverlap = false,
color = 'violet',
}) {
// A button which displays a modal in which the user can add or remove tags.
@ -556,19 +544,17 @@ export function AddTagsButton({
const hideFrequentTags = filterByOverlap && localTags && localTags.length > 0;
return <>
<Button
<IconButton
icon={active ? 'tags' : 'tag'}
color={color}
label={active ? 'Tags applied' : 'Add tag'}
role='primary'
onClick={handleOpen}
type="button"
type='button'
disabled={disabled}
/>
<Modal closeIcon
open={open}
onOpen={(e) => handleOpen(e)}
onClose={() => setOpen(false)}>
<Modal open={open} onClose={() => setOpen(false)}>
<Modal.Content>
{loading && <Dimmer active><Loader/></Dimmer>}
{loading && <Loading size='xs'>Updating tags…</Loading>}
<Header as='h4'>Applied Tags</Header>
{localTags && localTags.length > 0 ? selectedTagsGroup : emptySelectedTags}
@ -581,7 +567,7 @@ export function AddTagsButton({
<Divider/>
<Input
<TextInput
ref={filterInputRef}
placeholder='Filter tags...'
value={filterText}
@ -592,8 +578,7 @@ export function AddTagsButton({
addTag(filteredUnusedTags[0]);
}
}}
icon='search'
fluid
leftSection={<Icon name='search'/>}
style={{marginBottom: '0.5em'}}
/>
@ -604,18 +589,16 @@ export function AddTagsButton({
: 'You have no tags')}
</Modal.Content>
<Modal.Actions>
<Grid textAlign='left'>
<Grid.Row>
<Grid.Column width={8}>
{!hideEdit && <EditTagsModal/>}
</Grid.Column>
<Grid.Column width={8}>
<Button onClick={() => setOpen(false)} floated='right'>Close</Button>
{showAny && <Button color='violet' onClick={localOnAnyTag} floated='right'>Any</Button>}
<Button floated='right' secondary onClick={() => clearLocalTags()}>Clear</Button>
</Grid.Column>
</Grid.Row>
</Grid>
<Group justify='space-between'>
<Box>
{!hideEdit && <EditTagsModal/>}
</Box>
<Group gap={8}>
<Button role='cancel' onClick={() => clearLocalTags()}>Clear</Button>
{showAny && <Button role='primary' onClick={localOnAnyTag}>Any</Button>}
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Group>
</Group>
</Modal.Actions>
</Modal>
</>
@ -663,43 +646,37 @@ export const TagsSelector = ({
return button;
}
return <Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={2} computer={1}>
{button}
</Grid.Column>
<Grid.Column mobile={13} computer={14}>
<TagsLinkGroup tagNames={selectedTagNames}/>
</Grid.Column>
</Grid.Row>
</Grid>
return <Group align='flex-start' wrap='nowrap' gap={8}>
{button}
<Box style={{flex: 1}}>
<TagsLinkGroup tagNames={selectedTagNames}/>
</Box>
</Group>
}
export const TagsDashboard = () => {
const {tagNames, TagsLinkGroup} = React.useContext(TagsContext);
const tagPlaceholder = <GridColumn style={{width: 100}}><TagPlaceholder/></GridColumn>;
let availableTagsGroup = <Grid columns={3}>
<GridRow>
{tagPlaceholder}
{tagPlaceholder}
{tagPlaceholder}
</GridRow>
</Grid>;
const tagPlaceholder = <Box style={{width: 100}}><TagPlaceholder/></Box>;
let availableTagsGroup = <Group gap={12}>
{tagPlaceholder}
{tagPlaceholder}
{tagPlaceholder}
</Group>;
if (tagNames && tagNames.length >= 1) {
availableTagsGroup = <TagsLinkGroup tagNames={tagNames} style={{marginTop: '0.5em'}}/>;
} else if (tagNames === undefined) {
availableTagsGroup = <ErrorMessage>Could not fetch tags</ErrorMessage>
}
return <Segment>
return <Panel>
<Header as='h2'>Tags</Header>
{availableTagsGroup}
<Divider/>
<EditTagsModal/>
</Segment>
</Panel>
}
export const TagsProvider = (props) => {

View file

@ -1,5 +1,5 @@
import {emptyToNull} from "./components/Common";
import {toast} from "react-semantic-toasts-2";
import {toast} from "./components/ui";
import _ from "lodash";
import {
API_URI,

View file

@ -237,28 +237,12 @@ function ArchivePage() {
? isoDatetimeToAgoPopup(archiveFile.published_modified_datetime, true)
: 'unknown';
const aboutPane = {
menuItem: 'About', render: () => <Tab.Pane>
<Header as={'h3'}>Domain</Header>
{domainHeader}
<Header as='h3'>Size</Header>
{humanFileSize(size)}
<Header as={'h3'}>URL</Header>
<p>{archiveFile.url ? <a href={archiveFile.url}>{archiveFile.url}</a> : 'N/A'}</p>
<Header as={'h3'}>Modified Date</Header>
<p>{modifiedDatetimeString}</p>
</Tab.Pane>
};
// Helper to find file size from the files array by resolved path
const findFileSize = (resolvedPath) => {
if (!resolvedPath || !archiveFile.files) return null;
const file = archiveFile.files.find(i => String(i.path) === String(resolvedPath));
return 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;
};
@ -275,51 +259,11 @@ function ArchivePage() {
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>Singlefile File</strong></td>
<td>{localPreviewPath(data.singlefile_path, 'text/html')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability File</strong></td>
<td>{localPreviewPath(data.readability_path, 'text/html')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability Text File</strong></td>
<td>{localPreviewPath(data.readability_txt_path, 'text/plain')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability JSON File</strong></td>
<td>{localPreviewPath(data.readability_json_path, 'application/json')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Screenshot File</strong></td>
<td>
{screenshotPath
? <><PreviewPath path={screenshotPath} mimetype='image/*'
taggable={false}>{screenshotPath}</PreviewPath>{findFileSize(screenshotPath)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={archiveFile.directory}/></td>
</tr>
</tbody>
</table>
</Tab.Pane>
};
const tabPanes = [aboutPane, filesPane];
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
return <>
<BackButton/>
<Segment>
<Panel>
{screenshot}
<ExternalCardLink to={singlefileUrl}>
<Header as='h2'>{textEllipsis(archiveFile.title || data.url)}</Header>
@ -327,15 +271,13 @@ function ArchivePage() {
<Header as='h3'>Author: {archiveFile.author ? archiveFile.author : 'unknown'}</Header>
<Grid columns={2} stackable>
<GridRow>
<GridColumn>
<Header as='h4'>Published: {publishedDatetimeString}</Header>
</GridColumn>
<GridColumn>
<Header as='h4'>Downloaded: {downloadDatetimeString}</Header>
</GridColumn>
</GridRow>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<Header as='h4'>Published: {publishedDatetimeString}</Header>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<Header as='h4'>Downloaded: {downloadDatetimeString}</Header>
</Grid.Col>
</Grid>
{singlefileButton}
@ -344,25 +286,78 @@ function ArchivePage() {
{deleteButton}
{generateScreenshotButton}
<AddToPlaylistButton fileGroupId={archiveFile.id}/>
</Segment>
</Panel>
<Segment>
<Panel>
<TagsSelector
selectedTagNames={archiveFile['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'>
<Header as='h3'>Domain</Header>
{domainHeader}
<Segment>
<Header as='h3'>Size</Header>
{humanFileSize(size)}
<Header as='h3'>URL</Header>
<p>{archiveFile.url ? <a href={archiveFile.url}>{archiveFile.url}</a> : 'N/A'}</p>
<Header as='h3'>Modified Date</Header>
<p>{modifiedDatetimeString}</p>
</Tabs.Panel>
<Tabs.Panel value='files' pt='md'>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>Singlefile File</strong></td>
<td>{localPreviewPath(data.singlefile_path, 'text/html')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability File</strong></td>
<td>{localPreviewPath(data.readability_path, 'text/html')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability Text File</strong></td>
<td>{localPreviewPath(data.readability_txt_path, 'text/plain')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Readability JSON File</strong></td>
<td>{localPreviewPath(data.readability_json_path, 'application/json')}</td>
</tr>
<tr>
<td style={labelStyle}><strong>Screenshot File</strong></td>
<td>
{screenshotPath
? <><PreviewPath path={screenshotPath} mimetype='image/*'
taggable={false}>{screenshotPath}</PreviewPath>{findFileSize(screenshotPath)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={archiveFile.directory}/></td>
</tr>
</tbody>
</table>
</Tabs.Panel>
</Tabs>
<Panel>
<InfoHeader
headerContent='History'
popupContent='Other archives of this URL created at different times.'
/>
{historyList}
</Segment>
</Panel>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}

View file

@ -1,9 +1,6 @@
import React from "react";
import {Button, Form, Modal, Segment} from "./Theme";
import {Checkbox, Dropdown} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Button, Checkbox, Grid, Message, Modal, Panel, Select} from "./ui";
import {monthNames} from "./Common";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
export const dateRangeIsEmpty = (dateRange) => {
return dateRange[0] === null && dateRange[1] === null;
@ -39,9 +36,10 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) {
return <Checkbox
label={label}
checked={isChecked}
size='large'
onChange={(e, {checked}) => {
size='lg'
onChange={(e) => {
// Add or remove month index if the respective checkbox is checked.
const checked = e.currentTarget.checked;
if (checked) {
setMonthsSelected([...monthsSelected, idx]);
} else {
@ -51,36 +49,37 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) {
/>
}
return <Form>
<Grid>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('January')}</Grid.Column>
<Grid.Column>{monthCheckbox('February')}</Grid.Column>
<Grid.Column>{monthCheckbox('March')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('April')}</Grid.Column>
<Grid.Column>{monthCheckbox('May')}</Grid.Column>
<Grid.Column>{monthCheckbox('June')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('July')}</Grid.Column>
<Grid.Column>{monthCheckbox('August')}</Grid.Column>
<Grid.Column>{monthCheckbox('September')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={3}>
<Grid.Column>{monthCheckbox('October')}</Grid.Column>
<Grid.Column>{monthCheckbox('November')}</Grid.Column>
<Grid.Column>{monthCheckbox('December')}</Grid.Column>
</Grid.Row>
<Grid.Row columns={4}>
<Grid.Column><Button onClick={handleWinter} color='blue' size='small'>Winter</Button></Grid.Column>
<Grid.Column><Button onClick={handleSpring} color='green' size='small'>Spring</Button></Grid.Column>
<Grid.Column><Button onClick={handleSummer} color='yellow' size='small'>Summer</Button></Grid.Column>
<Grid.Column><Button onClick={handleFall} color='red' size='small'>Fall</Button></Grid.Column>
</Grid.Row>
</Grid>
</Form>
const monthRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.7em', marginBottom: '0.7em'};
const seasonRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '0.7em'};
return <div>
<div style={monthRowStyle}>
<div>{monthCheckbox('January')}</div>
<div>{monthCheckbox('February')}</div>
<div>{monthCheckbox('March')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('April')}</div>
<div>{monthCheckbox('May')}</div>
<div>{monthCheckbox('June')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('July')}</div>
<div>{monthCheckbox('August')}</div>
<div>{monthCheckbox('September')}</div>
</div>
<div style={monthRowStyle}>
<div>{monthCheckbox('October')}</div>
<div>{monthCheckbox('November')}</div>
<div>{monthCheckbox('December')}</div>
</div>
<div style={seasonRowStyle}>
<Button onClick={handleWinter} color='blue' size='small'>Winter</Button>
<Button onClick={handleSpring} color='green' size='small'>Spring</Button>
<Button onClick={handleSummer} color='yellow' size='small'>Summer</Button>
<Button onClick={handleFall} color='red' size='small'>Fall</Button>
</div>
</div>
}
export function DateRangeForm({dateRange, setDateRange}) {
@ -89,20 +88,16 @@ export function DateRangeForm({dateRange, setDateRange}) {
let yearRange = [];
for (let i = 1970; i <= currentYear; i++) {
yearRange = [...yearRange, {
key: i,
text: i,
value: i,
}];
yearRange = [...yearRange, {value: i.toString(), label: i.toString()}];
}
const handleFromYear = (e, {value}) => {
const handleFromYear = (value) => {
const toYear = dateRange[1] || currentYear;
setDateRange([value, toYear]);
setDateRange([value ? parseInt(value) : null, toYear]);
}
const handleToYear = (e, {value}) => {
setDateRange([dateRange[0], value]);
const handleToYear = (value) => {
setDateRange([dateRange[0], value ? parseInt(value) : null]);
}
React.useEffect(() => {
@ -113,37 +108,33 @@ export function DateRangeForm({dateRange, setDateRange}) {
}
}, [dateRange])
const errorMessage = error && <Message error content={error}/>;
const errorMessage = error && <Message kind='error'>{error}</Message>;
return <React.Fragment>
<Form>
<Grid>
<Grid.Row columns={2}>
<Grid.Column>
<Form.Field>
<label>From Year</label>
<Dropdown search scrolling fluid
options={yearRange}
value={dateRange[0]}
onChange={handleFromYear}
error={!!error}
/>
</Form.Field>
</Grid.Column>
<Grid.Column>
<Form.Field>
<label>To Year</label>
<Dropdown search scrolling fluid
options={yearRange.reverse()}
value={dateRange[1]}
onChange={handleToYear}
error={!!error}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<label>From Year</label>
<Select
searchable
data={yearRange}
value={dateRange[0] != null ? dateRange[0].toString() : null}
onChange={handleFromYear}
error={!!error}
maxDropdownHeight={280}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>To Year</label>
<Select
searchable
data={[...yearRange].reverse()}
value={dateRange[1] != null ? dateRange[1].toString() : null}
onChange={handleToYear}
error={!!error}
maxDropdownHeight={280}
/>
</Grid.Col>
</Grid>
{errorMessage}
</React.Fragment>
}
@ -221,7 +212,7 @@ export function DateSelectorButton({
return <React.Fragment>
<Button
icon='calendar alternate'
icon='calendar'
onClick={handleOpen}
color={color}
{...buttonProps}
@ -233,16 +224,16 @@ export function DateSelectorButton({
>
<Modal.Header>Filter by Published Date</Modal.Header>
<Modal.Content>
<Segment>
<Panel>
<DateRangeForm dateRange={dateRange} setDateRange={localOnSetDateRange}/>
</Segment>
<Segment>
</Panel>
<Panel>
<MonthsForm monthsSelected={monthsSelected} setMonthsSelected={setMonthsSelected}/>
</Segment>
</Panel>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClear} secondary>Clear</Button>
<Button onClick={handleClose}>Close</Button>
<Button onClick={handleClear} role='cancel'>Clear</Button>
<Button onClick={handleClose} role='primary'>Close</Button>
</Modal.Actions>
</Modal>
</React.Fragment>

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,4 @@
import React, {useContext, useState} from "react";
import {
CardContent,
CardDescription,
CardHeader,
CardMeta,
Checkbox,
Container,
Form,
Image,
Label,
PlaceholderLine,
Step,
TableCell,
TableHeaderCell,
} from "semantic-ui-react";
import {
CardGroupCentered,
CardPoster,
@ -25,7 +10,6 @@ import {
HandPointMessage,
humanFileSize,
isoDatetimeToAgoPopup,
mimetypeColor,
PageContainer,
Paginator,
PreviewLink,
@ -48,23 +32,29 @@ import {
useWROLMode
} from "../hooks/customHooks";
import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext";
import {IconArticle, IconLayoutList, IconSortAscending, IconSortDescending} from "@tabler/icons-react";
import {Link, Route, Routes, useSearchParams} from "react-router";
import {CardPlaceholder} from "./Placeholder";
import {ArchiveCard, ArchiveRowCells} from "./Archive";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {QueryContext} from "../contexts/contexts";
import {
Button,
Card,
Divider,
Group,
Header,
Icon,
IconButton,
Label,
Modal,
Panel,
Placeholder,
Popup,
Progress,
Segment
} from "./Theme";
Radio,
Stack,
Table,
Tooltip,
} from "./ui";
import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector";
import {SelectableTable} from "./Tables";
import {VideoCard, VideoRowCells} from "./Videos";
@ -78,14 +68,13 @@ import {FILES_MEDIA_URI} from "./Vars";
// Split a ts_headline snippet on our sentinel markers and wrap each match in <b>.
// We use sentinels instead of ts_headline's default <b> tags so the snippet can be
// safely rendered as React children without dangerouslySetInnerHTML.
function renderHighlightedSnippet(snippet, s) {
function renderHighlightedSnippet(snippet) {
const parts = snippet.split(/\[\[WROLPI_HL\]\]|\[\[\/WROLPI_HL\]\]/);
// Even indices are plain text, odd indices are highlighted matches.
return parts.map((part, i) => i % 2 === 1 ? <b key={i} {...s}>{part}</b> : <span key={i} {...s}>{part}</span>);
return parts.map((part, i) => i % 2 === 1 ? <b key={i}>{part}</b> : <span key={i}>{part}</span>);
}
function EbookCard({file, sortField}) {
const {s, t} = useContext(ThemeContext);
const [searchParams] = useSearchParams();
const query = searchParams.get('q') || '';
@ -105,70 +94,50 @@ function EbookCard({file, sortField}) {
detailUrl = `${detailUrl}?${params.toString()}`;
}
const color = mimetypeColor(file.mimetype, file.primary_path);
const title = file.title || file.stem || file.name;
const header = detailUrl
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis' {...t}>{title}</Link>
? <Link to={detailUrl} className='no-link-underscore card-link card-title-ellipsis'>{title}</Link>
: <ExternalCardLink to={viewerUrl || downloadUrl} className='card-title-ellipsis'>{title}</ExternalCardLink>;
return <Card color={color}>
<CardPoster file={file} to={detailUrl}/>
<CardContent {...s}>
<CardHeader>
<Container textAlign='left'>
<Popup on='hover'
trigger={header}
content={title}/>
</Container>
</CardHeader>
<CardMeta>
{file.author ? <b {...s}>{file.author}</b> : null}
{sortField === 'published_datetime'
? <p {...s}>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
: file.size && <p {...s}>{humanFileSize(file.size)}</p>
}
{hint && <p {...s} style={{fontSize: '0.85em', marginTop: '0.3em'}}>
<b {...s}>{hint.label}</b>
{hint.snippet && <span style={{...s, marginLeft: '0.4em', opacity: 0.8}}>
{renderHighlightedSnippet(hint.snippet, s)}
</span>}
</p>}
</CardMeta>
</CardContent>
</Card>
const meta = <>
{file.author ? <div><b>{file.author}</b></div> : null}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: file.size && <div>{humanFileSize(file.size)}</div>
}
{hint && <div style={{fontSize: '0.85em', marginTop: '0.3em'}}>
<b>{hint.label}</b>
{hint.snippet && <span style={{marginLeft: '0.4em', opacity: 0.8}}>
{renderHighlightedSnippet(hint.snippet)}
</span>}
</div>}
</>;
return <Card media={<CardPoster file={file} to={detailUrl}/>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
/>
}
function ImageCard({file}) {
const {s} = useContext(ThemeContext);
const url = `/media/${encodeMediaPath(file.primary_path)}`;
const title = file.title || file.stem || file.primary_path;
const header = <ExternalCardLink to={url} className='no-link-underscore card-link'>
<p>{textEllipsis(title)}</p>
<span>{textEllipsis(title)}</span>
</ExternalCardLink>;
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
return <Card color={mimetypeColor(file.mimetype)}>
<PreviewLink file={file}>
<CardPoster file={file}/>
</PreviewLink>
<CardContent {...s}>
<CardHeader>
<Popup on='hover'
trigger={header}
content={title}/>
</CardHeader>
<CardMeta {...s}>
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
</CardMeta>
<CardDescription {...s}>
<p>{humanFileSize(file.size)}</p>
</CardDescription>
</CardContent>
</Card>
const meta = <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{humanFileSize(file.size)}</div>
</>;
return <Card media={<PreviewLink file={file}><CardPoster file={file}/></PreviewLink>}
title={<Tooltip label={title}>{header}</Tooltip>}
meta={meta}
/>
}
function FileCard({file}) {
const {s} = useContext(ThemeContext);
const {sort} = useSearchOrder();
const sortField = sort ? sort.replace(/^-/, '') : null;
@ -189,31 +158,24 @@ function FileCard({file}) {
}
const author = file.author;
const color = mimetypeColor(file.mimetype, file.primary_path);
const size = file.size !== null && file.size !== undefined ? humanFileSize(file.size) : null;
const title = file.title || file.name || file.primary_path;
const dt = file.published_datetime || file.published_modified_datetime || file.modified;
return <Card color={color}>
<CardPoster file={file}/>
<CardContent {...s}>
<CardHeader>
<PreviewLink file={file}>
<Popup on='hover'
trigger={<span className='card-title-ellipsis'>{title}</span>}
content={title}/>
</PreviewLink>
</CardHeader>
{author && <b {...s}>{author}</b>}
{sortField === 'published_datetime'
? <p>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</p>
: <>
<p>{isoDatetimeToAgoPopup(dt, false)}</p>
<p>{size}</p>
</>
}
</CardContent>
</Card>
const header = <PreviewLink file={file}>
<Tooltip label={title}><span className='card-title-ellipsis'>{title}</span></Tooltip>
</PreviewLink>;
const meta = <>
{author && <div><b>{author}</b></div>}
{sortField === 'published_datetime'
? <div>{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}</div>
: <>
<div>{isoDatetimeToAgoPopup(dt, false)}</div>
<div>{size}</div>
</>
}
</>;
return <Card media={<CardPoster file={file}/>} title={header} meta={meta}/>
}
export function FileCards({files}) {
@ -222,7 +184,7 @@ export function FileCards({files}) {
{files.map(i => <FileCard key={i['primary_path']} file={i}/>)}
</CardGroupCentered>
} else if (files && files.length === 0) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
} else if (files === undefined) {
return <ErrorMessage>Could not search!</ErrorMessage>
}
@ -236,22 +198,22 @@ function ImageRowCells({file}) {
let poster = <FileIcon file={file} size='large'/>;
if (file.size && file.size < 50000000) {
// Image is less than 5mb, use it.
poster = <Image wrapped src={url} width='50px'/>;
poster = <img alt='' src={url} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center>
{poster}
</center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<p>{textEllipsis(file.title || file.stem || file.primary_path)}</p>
</PreviewLink>
</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</React.Fragment>
}
@ -260,21 +222,21 @@ export function EbookRowCells({file}) {
const posterPath = findPosterPath(file);
if (posterPath) {
const coverSrc = `/media/${encodeMediaPath(posterPath)}`;
cover = <Image wrapped src={coverSrc} width='50px'/>;
cover = <img alt='' src={coverSrc} width='50'/>;
}
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center>{cover}</center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<PreviewLink file={file}>
<FileRowTagIcon file={file}/>
{textEllipsis(file.title || file.stem)}
</PreviewLink>
</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
</React.Fragment>
}
@ -295,14 +257,14 @@ function FileRow({file}) {
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<Table.Cell>
<center><FileIcon file={file} size='large'/></center>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<FileRowTagIcon file={file}/>
<PreviewLink file={file}>{textEllipsis(file.title || file.name || file.stem)}</PreviewLink>
</TableCell>
<TableCell/>
</Table.Cell>
<Table.Cell/>
</React.Fragment>
}
@ -319,16 +281,12 @@ export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) {
rows={rows}
/>;
} else if (files) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
} else if (files === undefined) {
return <ErrorMessage>Could not search!</ErrorMessage>
}
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={3}/>
}
export function FileRowTagIcon({file}) {
@ -343,18 +301,18 @@ export function SearchViewButton({headlines}) {
if (headlines) {
// Cycle: cards -> headline -> list
if (view === 'headline') {
return <Button icon='list' onClick={() => setView('list')}/>;
return <IconButton icon='list' label='Switch to list view' onClick={() => setView('list')}/>;
} else if (view === 'list') {
return <Button icon='th' onClick={() => setView('cards')}/>;
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <Button icon='searchengin' onClick={() => setView('headline')}/>;
return <IconButton icon={IconArticle} label='Switch to headline view' onClick={() => setView('headline')}/>;
}
// Cycle: cards -> list
if (view === 'list') {
return <Button icon='th' onClick={() => setView('cards')}/>;
return <IconButton icon='th' label='Switch to cards view' onClick={() => setView('cards')}/>;
}
return <Button icon='browser' onClick={() => setView('list')}/>;
return <IconButton icon={IconLayoutList} label='Switch to list view' onClick={() => setView('list')}/>;
}
export function FilesView(
@ -379,7 +337,7 @@ export function FilesView(
if (view === 'list') {
const footer = selectElem ?
<>
<TableHeaderCell colSpan='3'>{selectElem}</TableHeaderCell>
<Table.HeaderCell colSpan='3'>{selectElem}</Table.HeaderCell>
</> : null;
body = <FileTable
files={files}
@ -423,15 +381,12 @@ export function SearchFilter({filters = [], modalHeader, size = 'medium'}) {
}
const filterFields = filters.map(
i => <Form.Field key={i['text']}>
<Checkbox radio
label={i['text']}
name='searchFilterRadioGroup'
checked={filter === i['value']}
value={i['value']}
onChange={() => setFilter(i['value'])}
/>
</Form.Field>
i => <Radio
key={i['text']}
label={i['text']}
checked={filter === i['value']}
onChange={() => setFilter(i['value'])}
/>
);
// Use violet color when filter has been applied.
@ -439,24 +394,19 @@ export function SearchFilter({filters = [], modalHeader, size = 'medium'}) {
if (filters && filters.length > 0) {
return <>
<Modal open={open} onOpen={() => handleOpen()} onClose={() => setOpen(false)} closeIcon>
<Modal open={open} onClose={() => setOpen(false)} closeIcon>
{modalHeader || <Modal.Header>Filter</Modal.Header>}
<Modal.Content>
<Form>
<Stack gap='xs'>
{filterFields}
</Form>
</Stack>
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClear} secondary>Clear</Button>
<Button onClick={() => setOpen(false)}>Close</Button>
<Button role='cancel' onClick={handleClear}>Clear</Button>
<Button role='cancel' onClick={() => setOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Button
icon='filter'
onClick={handleOpen}
color={buttonColor}
size={size}
/>
<IconButton icon='filter' label='Filter' onClick={handleOpen} color={buttonColor} size={size}/>
</>
}
@ -523,7 +473,7 @@ export function DocSearchFilterButton({size = 'medium'}) {
const DEFAULT_SEARCH_LIMIT = 24;
// A single section within the comprehensive SearchFilterModal. Vertical spacing between sections is
// provided by the surrounding Grid rows.
// provided by the surrounding Stack.
function SearchFilterSection({header, children}) {
return <>
<Header as='h4' style={{marginBottom: '0.6em'}}>{header}</Header>
@ -662,29 +612,22 @@ export function SearchFilterModal(
style={{margin: '0.2em'}}
>{o['text']}</Button>
);
const directionToggle = <Button icon labelPosition='left'
const directionToggle = <Button icon={desc ? IconSortDescending : IconSortAscending}
onClick={() => {
const base = sortKey || sorts[0]['value'];
setDraftSort(desc ? base : `-${base}`);
}}>
<Icon name={desc ? 'sort amount down' : 'sort amount up'}/>
{desc ? 'Descending' : 'Ascending'}
</Button>;
// File-type filter section (single-select radios; clicking the active one clears it). Laid out in
// columns so more options fit on each line.
// File-type filter section (single-select radios; clicking the active one clears it). Wraps so more
// options fit on each line.
const filterFields = fileFilterOptions && fileFilterOptions.map(i =>
<Grid.Column mobile={8} tablet={5} computer={4} key={i['value']}>
<Form.Field>
<Checkbox radio
label={i['text']}
name='searchFilterRadioGroup'
checked={draftFilter === i['value']}
value={i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
</Form.Field>
</Grid.Column>
<Radio key={i['value']}
label={i['text']}
checked={draftFilter === i['value']}
onChange={() => setDraftFilter(draftFilter === i['value'] ? null : i['value'])}
/>
);
// Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by
@ -699,31 +642,23 @@ export function SearchFilterModal(
return <Modal open={open} onClose={handleClose} closeIcon size='small'>
<Modal.Header>Search Filters</Modal.Header>
<Modal.Content scrolling>
<Grid>
<Modal.Content>
<Stack gap='lg'>
{sorts &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Sort By'>
<div style={{marginBottom: '1em'}}>{directionToggle}</div>
<div>{sortButtons}</div>
</SearchFilterSection>}
{fileFilterOptions &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='File Type'>
<Form><Grid>{filterFields}</Grid></Form>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='File Type'>
<Group gap='md'>{filterFields}</Group>
</SearchFilterSection>}
{(showTags || showLimit) &&
<Grid.Row columns={2}>
<Group align='flex-start' grow wrap='wrap'>
{showTags &&
<Grid.Column>
<div style={{minWidth: 220}}>
<SearchFilterSection header='Tags'>
<TagsSelector hideGroup hideEdit showAny
active={draftAnyTag || (draftTags && draftTags.length > 0)}
@ -742,54 +677,42 @@ export function SearchFilterModal(
style={{marginLeft: '0.3em', marginTop: '0.3em'}}
/>
</SearchFilterSection>
</Grid.Column>}
</div>}
{showLimit &&
<Grid.Column>
<div style={{minWidth: 160}}>
<SearchFilterSection header='Results Per Page'>
<div>{limitButtons}</div>
</SearchFilterSection>
</Grid.Column>}
</Grid.Row>}
</div>}
</Group>}
{showCensored &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Availability'>
<Toggle label='Only censored (no longer available to download)'
checked={draftCensored}
onChange={setDraftCensored}
/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Availability'>
<Toggle label='Only censored (no longer available to download)'
checked={draftCensored}
onChange={setDraftCensored}
/>
</SearchFilterSection>}
{showDeep &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>}
{showDates &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
</Grid>
<SearchFilterSection header='Published Date'>
<MonthsForm monthsSelected={draftMonths} setMonthsSelected={setDraftMonths}/>
<Divider/>
<DateRangeForm dateRange={draftRange} setDateRange={setDraftRange}/>
</SearchFilterSection>}
</Stack>
</Modal.Content>
<Modal.Actions>
<Button secondary floated='left' onClick={handleClearAll}>Clear All</Button>
<Button primary onClick={handleClose}>Done</Button>
<Button role='cancel' onClick={handleClearAll} style={{marginRight: 'auto'}}>Clear All</Button>
<Button role='primary' onClick={handleClose}>Done</Button>
</Modal.Actions>
</Modal>
}
@ -840,11 +763,10 @@ export function SearchFilterButton(
const active = count > 0;
return <>
<Button color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
<Icon name='filter'/>
<Button icon='filter' color={active ? 'violet' : 'grey'} size={size} onClick={() => setOpen(true)}>
{content}
{count > 0 &&
<Label circular size='tiny' style={{marginLeft: '0.7em'}}>{count}</Label>}
<Label style={{marginLeft: '0.7em'}}>{count}</Label>}
</Button>
<SearchFilterModal
open={open}
@ -909,13 +831,13 @@ export function DeepSearchHint({searchStr, files}) {
return null;
}
return <Segment>
return <Panel>
{files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'}
<Button primary
<Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>
</Segment>
</Panel>
}
export function FilesSearchView({
@ -951,22 +873,22 @@ export function FilesSearchView({
? deepEstimate - total : null;
// Enable deep search and reset pagination in a single update (like the filter modal does), so the
// user starts at page 1 of the deep results.
const deepButton = <Button primary
const deepButton = <Button role='primary'
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>;
if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) {
deepHint = <Segment>
deepHint = <Panel>
{moreCount > 0
? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.`
: 'Not finding what you need?'}
{deepButton}
</Segment>;
</Panel>;
} else if (searchFiles.length > 0 && moreCount > 0) {
deepHint = <Segment>
deepHint = <Panel>
{`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`}
{deepButton}
</Segment>;
</Panel>;
}
}
@ -1041,12 +963,29 @@ function isGlobalRefresh(progress) {
const REFRESH_PHASES = [
{key: 1, icon: 'search', title: 'Counting', description: 'Finding files'},
{key: 2, icon: 'database', title: 'Inserting', description: 'Updating database'},
{key: 3, icon: 'cogs', title: 'Modeling', description: 'Extracting metadata'},
{key: 2, icon: 'disk', title: 'Inserting', description: 'Updating database'},
{key: 3, icon: 'settings', title: 'Modeling', description: 'Extracting metadata'},
{key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'},
{key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'},
];
// Replaces Semantic's Step.Group/Step: a row of phase markers, brightness/weight (via the active
// border) carrying the current stage rather than a bespoke component from the shared library.
function RefreshStepItem({phase, isActive, isCompleted, isDisabled, description}) {
return <div style={{
display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px', padding: '0.5em 0.75em',
opacity: isDisabled ? 0.5 : 1,
borderBottom: isActive ? '2px solid var(--blue)' : '2px solid transparent',
}}>
<Icon name={phase.icon} size='large'
style={{color: isCompleted ? 'var(--green)' : isActive ? 'var(--blue)' : 'var(--muted)'}}/>
<div>
<div style={{fontWeight: 600, fontSize: 13}}>{phase.title}</div>
<div style={{fontSize: 12, color: 'var(--muted)'}}>{isActive || isCompleted ? description : ''}</div>
</div>
</div>
}
export function RefreshSteps({progress}) {
const currentPhase = getRefreshPhase(progress?.status);
const {operation_processed, operation_total, status} = progress || {};
@ -1055,44 +994,30 @@ export function RefreshSteps({progress}) {
return null;
}
return (
<Step.Group size='mini' fluid>
{REFRESH_PHASES.map(phase => {
const isCompleted = phase.key < currentPhase;
const isActive = phase.key === currentPhase;
const isDisabled = phase.key > currentPhase;
return <div style={{display: 'flex', flexWrap: 'wrap', border: '1px solid var(--border)'}}>
{REFRESH_PHASES.map(phase => {
const isCompleted = phase.key < currentPhase;
const isActive = phase.key === currentPhase;
const isDisabled = phase.key > currentPhase;
// Determine description for active phase
let description = phase.description;
if (isActive) {
if (operation_total > 0) {
description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`;
} else if (status === 'comparing') {
description = 'Comparing files...';
} else if (status === 'upserting') {
description = 'Updating files...';
} else if (status === 'deleting') {
description = 'Removing deleted...';
}
// Determine description for active phase
let description = phase.description;
if (isActive) {
if (operation_total > 0) {
description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`;
} else if (status === 'comparing') {
description = 'Comparing files...';
} else if (status === 'upserting') {
description = 'Updating files...';
} else if (status === 'deleting') {
description = 'Removing deleted...';
}
}
return (
<Step
key={phase.key}
active={isActive}
completed={isCompleted}
disabled={isDisabled}
>
<Icon name={phase.icon}/>
<Step.Content>
<Step.Title>{phase.title}</Step.Title>
<Step.Description>{isActive || isCompleted ? description : ''}</Step.Description>
</Step.Content>
</Step>
);
})}
</Step.Group>
);
return <RefreshStepItem key={phase.key} phase={phase} isActive={isActive} isCompleted={isCompleted}
isDisabled={isDisabled} description={description}/>
})}
</div>
}
function RefreshProgressBar({status, operation_total, operation_processed, operation_percent, error}) {
@ -1104,7 +1029,7 @@ function RefreshProgressBar({status, operation_total, operation_processed, opera
// Show error state
if (status === 'error' && error) {
return <Progress error percent={100}>{`Error: ${error}`}</Progress>;
return <Progress percent={100} color='red' label={`Error: ${error}`}/>;
}
let label = `${phaseLabel}`;
@ -1112,7 +1037,7 @@ function RefreshProgressBar({status, operation_total, operation_processed, opera
label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`;
}
return <Progress active color='violet' percent={operation_percent || 0} progress>{label}</Progress>;
return <Progress percent={operation_percent || 0} color='violet' label={label}/>;
}
export function FilesRefreshProgress() {
@ -1214,12 +1139,13 @@ const useRefresh = () => {
export function FilesRefreshButton({paths, showLabel}) {
const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh();
const disabled = wrolModeEnabled || globalRefreshing;
const busy = loading || globalRefreshing;
return <Button icon='refresh'
content={showLabel ? 'Refresh' : undefined}
labelPosition={showLabel ? 'left' : undefined}
loading={loading || globalRefreshing}
onClick={() => refreshFiles(paths)}
disabled={wrolModeEnabled || globalRefreshing}/>
;
if (showLabel) {
return <Button role='cancel' icon='refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}>Refresh</Button>;
}
return <IconButton role='cancel' icon='refresh' label='Refresh' loading={busy} disabled={disabled}
onClick={() => refreshFiles(paths)}/>;
}

View file

@ -1,7 +1,5 @@
import React, {useContext} from "react";
import {Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Header, Modal} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
import React from "react";
import {Button, Header, Modal, Table} from "./ui";
import {SHORTCUTS} from "./KeyboardShortcutsProvider";
import {usePlatformModifier} from "../hooks/customHooks";
@ -9,13 +7,11 @@ import {usePlatformModifier} from "../hooks/customHooks";
function KeyboardKey({children}) {
return (
<kbd style={{
backgroundColor: '#f7f7f7',
border: '1px solid #ccc',
borderRadius: '3px',
boxShadow: '0 1px 0 rgba(0,0,0,0.2)',
color: '#333',
backgroundColor: 'var(--head)',
border: '1px solid var(--border)',
color: 'var(--text)',
display: 'inline-block',
fontFamily: 'monospace',
fontFamily: 'var(--font-mono)',
fontSize: '0.85em',
lineHeight: '1.4',
margin: '0 2px',
@ -116,7 +112,6 @@ function groupShortcutsByCategory(shortcuts) {
}
export default function HelpModal({open, onClose}) {
const {i, t} = useContext(ThemeContext);
const {isMac} = usePlatformModifier();
const groupedShortcuts = groupShortcutsByCategory(SHORTCUTS);
@ -125,29 +120,29 @@ export default function HelpModal({open, onClose}) {
const orderedCategories = categoryOrder.filter(cat => groupedShortcuts[cat]);
return (
<Modal closeIcon open={open} onClose={onClose} size='small'>
<Modal open={open} onClose={onClose} size='small'>
<Modal.Header>Keyboard Shortcuts</Modal.Header>
<Modal.Content scrolling>
<Modal.Content>
{orderedCategories.map(category => (
<div key={category} style={{marginBottom: '1.5em'}}>
<Header as='h4' {...t}>{category}</Header>
<Table basic='very' compact {...i}>
<TableBody>
<Header as='h4'>{category}</Header>
<Table>
<Table.Body>
{groupedShortcuts[category].map((shortcut, idx) => (
<TableRow key={idx}>
<TableCell width={6}>
<Table.Row key={idx}>
<Table.Cell width={6}>
<ShortcutKeys keys={shortcut.keys} isMac={isMac}/>
</TableCell>
<TableCell {...t}>{shortcut.description}</TableCell>
</TableRow>
</Table.Cell>
<Table.Cell>{shortcut.description}</Table.Cell>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
</div>
))}
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
);

View file

@ -15,19 +15,10 @@ import {
deleteMapFile, deleteMapPin, fetchMapSubscriptions, getMapFiles, getMapPins,
mapSubscribe, mapUnsubscribe, rebuildMapSearchIndex, updateMapPin
} from "../api";
import {
Button,
Divider,
Input,
TableCell,
TableRow
} from "semantic-ui-react";
import {Button, Divider, Header, Icon, Modal, Table, TextInput} from "./ui";
import {SortableTable} from "./SortableTable";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Header} from "./Theme";
import {Media, StatusContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
import {Modal} from "./Theme";
import MapViewer from "./MapViewer";
import {AddToPlaylistButton} from "./AddToPlaylist";
import maplibregl from "maplibre-gl";
@ -124,15 +115,16 @@ function RegionPreviewModal({bbox, name, open, onClose}) {
return () => { if (map) map.remove(); };
}, [open, bbox]);
return <Modal open={open} onClose={onClose} size='large' closeIcon>
<Modal.Header>{name}</Modal.Header>
<Modal.Content>
<div ref={mapContainer} style={{width: '100%', height: '50vh'}}/>
</Modal.Content>
return <Modal open={open} onClose={onClose} size='large' closeIcon title={name}>
{/* No `.media` here: the bbox highlight is drawn as a map layer on the canvas itself
(see the `bbox` source/layers above), so the automatic `canvas` element-type filter
already covers it. There is no separate DOM overlay to wrap. */}
<div ref={mapContainer} style={{width: '100%', height: '50vh'}}/>
</Modal>;
}
// Random-ish distinct colors for region boxes.
// Random-ish distinct colors for region boxes. These are map-data paint colors (drawn
// on the WebGL canvas itself), not UI chrome, so they are not theme tokens.
const REGION_COLORS = [
'#6435c9', '#e03997', '#00b5ad', '#2185d0', '#f2711c', '#b5cc18',
'#a333c8', '#21ba45', '#db2828', '#fbbd08', '#a5673f', '#767676',
@ -211,11 +203,9 @@ function AllRegionsPreviewModal({catalog, open, onClose}) {
return () => { if (map) map.remove(); };
}, [open, catalog]);
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon>
<Modal.Header>All Map Regions</Modal.Header>
<Modal.Content>
<div ref={mapContainer} style={{width: '100%', height: '75vh'}}/>
</Modal.Content>
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon title='All Map Regions'>
{/* No `.media` here either — same reasoning as RegionPreviewModal. */}
<div ref={mapContainer} style={{width: '100%', height: '75vh'}}/>
</Modal>;
}
@ -235,30 +225,31 @@ function MapCatalogRow({item, subscribedRegions, fetchData}) {
}
};
return <TableRow>
<TableCell>{name}</TableCell>
<TableCell>{humanFileSize(size_estimate)}</TableCell>
<TableCell collapsing>
return <Table.Row>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{humanFileSize(size_estimate)}</Table.Cell>
<Table.Cell>
{bbox && <Button
size='tiny'
size='xs'
icon='eye'
onClick={() => setPreviewOpen(true)}
/>}
<Button
color={isSubscribed ? 'red' : 'violet'}
size='tiny'
role={isSubscribed ? 'danger' : undefined}
color={isSubscribed ? undefined : 'violet'}
size='xs'
onClick={handleButton}
>
{isSubscribed ? 'Unsubscribe' : 'Subscribe'}
</Button>
</TableCell>
</Table.Cell>
{previewOpen && <RegionPreviewModal
bbox={bbox}
name={name}
open={previewOpen}
onClose={() => setPreviewOpen(false)}
/>}
</TableRow>;
</Table.Row>;
}
function ManageMap() {
@ -319,38 +310,39 @@ function ManageMap() {
};
const fileRowFunc = (file) => {
return <TableRow key={file.name}>
<TableCell>{file.name}</TableCell>
<TableCell>{humanFileSize(file.size)}</TableCell>
<TableCell collapsing>
return <Table.Row key={file.name}>
<Table.Cell>{file.name}</Table.Cell>
<Table.Cell>{humanFileSize(file.size)}</Table.Cell>
<Table.Cell>
{file.name.startsWith('terrain')
? null
: file.has_search_index
? <Button size='tiny' icon='check' content='Built' disabled color='green'/>
: <APIButton size='tiny' icon='search'
content={searchBuilding ? 'Building...' : 'Build'}
? <Button size='xs' icon='check' disabled role='save'>Built</Button>
: <APIButton size='xs' icon='search'
disabled={searchBuilding}
loading={searchBuilding}
onClick={() => handleBuildSearch(file.name)}/>
onClick={() => handleBuildSearch(file.name)}>
{searchBuilding ? 'Building...' : 'Build'}
</APIButton>
}
</TableCell>
<TableCell collapsing>
</Table.Cell>
<Table.Cell>
<APIButton
color='red'
size='tiny'
role='danger'
size='xs'
confirmContent='Delete this map file?'
confirmButton='Delete'
onClick={() => handleDelete(file.name)}
>
Delete
</APIButton>
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
};
const emptyRow = <TableRow>
<TableCell colSpan={4}>No PMTiles map files were found in <b>map/</b></TableCell>
</TableRow>;
const emptyRow = <Table.Row>
<Table.Cell colSpan={4}>No PMTiles map files were found in <b>map/</b></Table.Cell>
</Table.Row>;
// --- Catalog table ---
const catalogHeaders = [
@ -392,7 +384,7 @@ function ManageMap() {
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5em'}}>
<Header as='h3'>Map Subscriptions</Header>
<Button size='tiny' icon='globe' content='View All Regions' onClick={() => setAllRegionsOpen(true)}/>
<Button size='xs' icon='globe' onClick={() => setAllRegionsOpen(true)}>View All Regions</Button>
</div>
<AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen} onClose={() => setAllRegionsOpen(false)}/>
@ -406,7 +398,7 @@ function ManageMap() {
/>
<InfoMessage storageName='hint_map_subscribe'>
<Message.Header>How to get map files</Message.Header>
<div style={{fontWeight: 600, marginBottom: 4}}>How to get map files</div>
<p>Subscribe to a map region to automatically download it. Subscriptions will
periodically check for updated maps. Downloads may take a long time depending on
the region size and your internet speed. You can monitor progress on
@ -432,8 +424,8 @@ function PinEditRow({pin, onSave, onCancel}) {
const [label, setLabel] = useState(pin.label);
const [color, setColor] = useState(pin.color);
return <TableRow>
<TableCell collapsing>
return <Table.Row>
<Table.Cell>
<div style={{display: "flex", gap: 4}}>
{PIN_COLORS.map(c =>
<div
@ -441,27 +433,27 @@ function PinEditRow({pin, onSave, onCancel}) {
onClick={() => setColor(c)}
style={{
width: 20, height: 20, borderRadius: "50%", background: c, cursor: "pointer",
border: color === c ? "3px solid #333" : "2px solid #ccc",
border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
</div>
</TableCell>
<TableCell>
<Input
size='small'
</Table.Cell>
<Table.Cell>
<TextInput
size='sm'
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && label.trim() && onSave(pin.id, label.trim(), color)}
/>
</TableCell>
<TableCell>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</TableCell>
<Media greaterThan='mobile'><TableCell>{pin.created}</TableCell></Media>
<TableCell collapsing>
<Button size='tiny' color='green' onClick={() => label.trim() && onSave(pin.id, label.trim(), color)}>Save</Button>
<Button size='tiny' onClick={onCancel}>Cancel</Button>
</TableCell>
</TableRow>;
</Table.Cell>
<Table.Cell>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</Table.Cell>
<Media greaterThan='mobile'><Table.Cell>{pin.created}</Table.Cell></Media>
<Table.Cell>
<Button size='xs' role='save' onClick={() => label.trim() && onSave(pin.id, label.trim(), color)}>Save</Button>
<Button size='xs' role='cancel' onClick={onCancel}>Cancel</Button>
</Table.Cell>
</Table.Row>;
}
function MapPins() {
@ -517,38 +509,38 @@ function MapPins() {
onCancel={() => setEditingId(null)}
/>;
}
return <TableRow key={pin.id}>
<TableCell collapsing>
return <Table.Row key={pin.id}>
<Table.Cell>
<div style={{
width: 16, height: 16, borderRadius: "50%",
background: pin.color || "red", border: "1px solid #ccc",
background: pin.color || "red", border: "1px solid var(--border)",
}}/>
</TableCell>
<TableCell>{pin.label}</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>{pin.label}</Table.Cell>
<Table.Cell>
<a href="#" onClick={(e) => {e.preventDefault(); handleNavigate(pin);}}>
{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}
</a>
</TableCell>
<TableCell>{pin.created}</TableCell>
<TableCell collapsing>
<Button size='tiny' icon='edit' onClick={() => setEditingId(pin.id)}/>
</Table.Cell>
<Table.Cell>{pin.created}</Table.Cell>
<Table.Cell>
<Button size='xs' icon='edit' onClick={() => setEditingId(pin.id)}/>
<AddToPlaylistButton
size='tiny'
size='xs'
icon='list'
content=''
url={{url: `/map?lat=${pin.lat}&lon=${pin.lon}&z=14`, title: pin.label}}
/>
<APIButton
size='tiny'
color='red'
size='xs'
role='danger'
icon='trash'
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
};
// --- Mobile headers and row ---
@ -559,35 +551,35 @@ function MapPins() {
];
const mobileRowFunc = (pin) => {
return <TableRow key={pin.id}>
<TableCell collapsing>
return <Table.Row key={pin.id}>
<Table.Cell>
<div style={{
width: 16, height: 16, borderRadius: "50%",
background: pin.color || "red", border: "1px solid #ccc",
background: pin.color || "red", border: "1px solid var(--border)",
}}/>
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
<a href="#" onClick={(e) => {e.preventDefault(); handleNavigate(pin);}}>
{pin.label}
</a>
<div style={{fontSize: 11, color: '#888'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
</TableCell>
<TableCell collapsing>
<div style={{fontSize: 11, color: 'var(--muted)'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
</Table.Cell>
<Table.Cell>
<APIButton
size='tiny'
color='red'
size='xs'
role='danger'
icon='trash'
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
};
const emptyRow = <TableRow>
<TableCell colSpan={5}>No pins. Right-click on the map to add one.</TableCell>
</TableRow>;
const emptyRow = <Table.Row>
<Table.Cell colSpan={5}>No pins. Right-click on the map to add one.</Table.Cell>
</Table.Row>;
const lowerFilter = filter.toLowerCase();
const filteredPins = lowerFilter
@ -598,8 +590,8 @@ function MapPins() {
: pins;
return <PageContainer>
<Input
icon='search'
<TextInput
leftSection={<Icon name='search'/>}
placeholder='Filter by label or coordinates...'
value={filter}
onChange={(e) => setFilter(e.target.value)}
@ -607,7 +599,6 @@ function MapPins() {
/>
<Media at='mobile'>
<SortableTable
tableProps={{unstackable: true}}
tableHeaders={mobileHeaders}
data={filteredPins}
rowFunc={mobileRowFunc}
@ -618,7 +609,6 @@ function MapPins() {
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{unstackable: true}}
tableHeaders={fullHeaders}
data={filteredPins}
rowFunc={fullRowFunc}
@ -651,4 +641,4 @@ export function MapRoute() {
<Route path='manage' exact element={<ManageMap/>}/>
</Routes>
</div>
}
}

View file

@ -1,11 +1,10 @@
import React, {useEffect, useState} from "react";
import {Link, useNavigate} from "react-router";
import {CardContent, CardDescription, CardGroup, CardHeader, CardMeta} from "semantic-ui-react";
import {getMapSearchStatus, searchMap} from "../api";
import {Card, Header, Loader, Segment} from "./Theme";
import {Card, CardGroup, Header, Loading, Panel} from "./ui";
import {Paginator} from "./Common";
import {useLatestRequest, usePages} from "../hooks/customHooks";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {QueryContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
function formatPopulation(pop) {
@ -17,7 +16,6 @@ function formatPopulation(pop) {
function MapPlaceCard({place, index}) {
const navigate = useNavigate();
const {s} = React.useContext(ThemeContext);
const [iframeSrc, setIframeSrc] = useState(null);
const zoom = place.min_zoom ? Math.max(8, Math.round(13 - place.min_zoom / 3)) : 11;
@ -40,27 +38,27 @@ function MapPlaceCard({place, index}) {
const pop = formatPopulation(place.population);
if (pop) subtitle.push(`pop. ${pop}`);
return <Card onClick={handleClick} style={{cursor: "pointer"}}>
<div style={{height: 150, overflow: "hidden", position: "relative", background: "#1a1a2e"}}>
{iframeSrc && <iframe
src={iframeSrc}
title={place.name}
loading="lazy"
style={{
width: "100%",
height: "100%",
border: "none",
pointerEvents: "none",
}}
/>}
</div>
<CardContent {...s}>
<CardHeader {...s}>{place.name}</CardHeader>
{subtitle.length > 0 && <CardMeta {...s}>{subtitle.join(" \u00b7 ")}</CardMeta>}
<CardDescription {...s}>
{place.lat.toFixed(4)}, {place.lon.toFixed(4)}
</CardDescription>
</CardContent>
const media = <div style={{height: 150, overflow: "hidden", position: "relative", background: "var(--head)"}}>
{iframeSrc && <iframe
src={iframeSrc}
title={place.name}
loading="lazy"
style={{
width: "100%",
height: "100%",
border: "none",
pointerEvents: "none",
}}
/>}
</div>;
return <Card
onClick={handleClick}
media={media}
title={place.name}
meta={subtitle.length > 0 ? subtitle.join(" · ") : undefined}
>
<div>{place.lat.toFixed(4)}, {place.lon.toFixed(4)}</div>
</Card>;
}
@ -101,33 +99,33 @@ export function MapSearchView() {
}, [data, setTotal]);
if (hasMaps === false) {
return <Segment>
return <Panel>
<Header as="h4">No maps are installed.</Header>
<p>Subscribe to a map region to search for places.</p>
<Link to="/map/manage">Go to Map Manager</Link>
</Segment>;
</Panel>;
}
if (loading) {
return <Segment><Loader active inline="centered"/></Segment>;
return <Panel><Loading/></Panel>;
}
if (!searchStr) {
return <Segment><Header as="h4">Enter a search query to find map locations.</Header></Segment>;
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
}
if (results.length === 0) {
return <Segment><Header as="h4">No map locations found for "{searchStr}".</Header></Segment>;
return <Panel><Header as="h4">No map locations found for "{searchStr}".</Header></Panel>;
}
return <React.Fragment>
<CardGroup itemsPerRow={3} stackable style={{marginTop: "1em"}}>
<CardGroup minWidth={220}>
{results.map((place, i) =>
<MapPlaceCard key={`${place.name}-${place.lat}-${place.lon}-${i}`} place={place} index={i}/>
)}
</CardGroup>
<center style={{marginTop: '2em'}}>
<div style={{textAlign: 'center', marginTop: '2em'}}>
<Paginator activePage={activePage} totalPages={totalPages} onPageChange={setPage}/>
</center>
</div>
</React.Fragment>;
}

View file

@ -7,8 +7,7 @@ import layers from "protomaps-themes-base";
import mlcontour from "@acalcutt/maplibre-contour-pmtiles";
import {addMapPin, deleteMapPin, getMapFiles, getMapPins, searchMap, setMapDefaultLocation} from "../api";
import {MAP_VIEWER_URI} from "./Vars";
import {Checkbox, Icon, Segment} from "semantic-ui-react";
import {Header} from "./Theme";
import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
import {SettingsContext, ThemeContext} from "../contexts/contexts";
// Terrain DEM file prefix for hillshade and contours.
@ -256,14 +255,17 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
});
}, [map, visibilityRef]);
// A floating control drawn over the map canvas: it is part of the app's interface,
// not map data, so it is never filtered — it stays plain, token-colored chrome in
// every theme, same as the nav bar.
const panelStyle = {
position: "absolute",
top: 10,
left: 10,
zIndex: 1000,
background: "white",
borderRadius: 8,
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
background: "var(--panel)",
border: "1px solid var(--border)",
color: "var(--text)",
maxHeight: "calc(100vh - 120px)",
overflowY: "auto",
minWidth: iconOnly ? 0 : 200,
@ -279,7 +281,7 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
: {
padding: "8px 12px", cursor: "pointer", fontWeight: 600,
display: "flex", justifyContent: "space-between", alignItems: "center",
borderBottom: collapsed ? "none" : "1px solid #eee", userSelect: "none",
borderBottom: collapsed ? "none" : "1px solid var(--border)", userSelect: "none",
};
return <div style={panelStyle}>
@ -302,16 +304,15 @@ function LayerControl({map, scaleUnit, onScaleUnitChange, visibilityRef}) {
<Checkbox
label={name}
checked={visibility[name]}
onChange={(e, {checked}) => toggleGroup(name, checked)}
onChange={(e) => toggleGroup(name, e.currentTarget.checked)}
/>
</div>
)}
<div style={{borderTop: "1px solid #eee", marginTop: 6, paddingTop: 6}}>
<Checkbox
toggle
<div style={{borderTop: "1px solid var(--border)", marginTop: 6, paddingTop: 6}}>
<Toggle
label={scaleUnit === "imperial" ? "Imperial" : "Metric"}
checked={scaleUnit === "imperial"}
onChange={(e, {checked}) => onScaleUnitChange(checked ? "imperial" : "metric")}
onChange={(e) => onScaleUnitChange(e.currentTarget.checked ? "imperial" : "metric")}
/>
</div>
</div>}
@ -324,21 +325,22 @@ function AddPinDialog({lat, lon, onSubmit, onCancel}) {
const [label, setLabel] = useState("");
const [color, setColor] = useState("red");
// A floating dialog over the canvas — page chrome, not map data, so it is never
// filtered and keeps flat, bordered surfaces like the rest of the app (no shadow).
return <div style={{
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
zIndex: 1002, background: "white", borderRadius: 8, boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
zIndex: 1002, background: "var(--panel)", border: "1px solid var(--border)", color: "var(--text)",
padding: 16, minWidth: 250,
}}>
<div style={{fontWeight: 600, marginBottom: 8}}>Add Pin</div>
<div style={{fontSize: 12, color: "#666", marginBottom: 8}}>{lat.toFixed(4)}, {lon.toFixed(4)}</div>
<input
type="text"
<div style={{fontSize: 12, color: "var(--muted)", marginBottom: 8}}>{lat.toFixed(4)}, {lon.toFixed(4)}</div>
<TextInput
placeholder="Label"
value={label}
onChange={e => setLabel(e.target.value)}
autoFocus
onKeyDown={e => e.key === "Enter" && label.trim() && onSubmit(label.trim(), color)}
style={{width: "100%", padding: "6px 8px", marginBottom: 8, border: "1px solid #ccc", borderRadius: 4}}
style={{marginBottom: 8}}
/>
<div style={{display: "flex", gap: 6, marginBottom: 12}}>
{PIN_COLORS.map(c =>
@ -347,20 +349,21 @@ function AddPinDialog({lat, lon, onSubmit, onCancel}) {
onClick={() => setColor(c)}
style={{
width: 24, height: 24, borderRadius: "50%", background: c, cursor: "pointer",
border: color === c ? "3px solid #333" : "2px solid #ccc",
border: color === c ? "3px solid var(--text)" : "2px solid var(--border)",
}}
/>
)}
</div>
<div style={{display: "flex", gap: 8, justifyContent: "flex-end"}}>
<button onClick={onCancel} style={{padding: "4px 12px", cursor: "pointer"}}>Cancel</button>
<button
<Button role='cancel' size='sm' onClick={onCancel}>Cancel</Button>
<Button
role='primary'
size='sm'
onClick={() => label.trim() && onSubmit(label.trim(), color)}
disabled={!label.trim()}
style={{padding: "4px 12px", cursor: "pointer", background: "#6435c9", color: "white", border: "none", borderRadius: 4}}
>
Add
</button>
</Button>
</div>
</div>;
}
@ -474,6 +477,8 @@ function MapSearch({map}) {
return parts.join(" \u00b7 ");
};
// Floating search chrome over the canvas — page interface, not map data, so it is
// never filtered. Flat surfaces: bordered, no box-shadow, token colors only.
return <div ref={containerRef} style={{
position: "absolute", top: 10, left: "50%", transform: "translateX(-50%)",
zIndex: 5, width: 320, maxWidth: "calc(100% - 100px)",
@ -486,24 +491,23 @@ function MapSearch({map}) {
onFocus={() => results.length > 0 && setShowResults(true)}
onKeyDown={handleKeyDown}
style={{
width: "100%", padding: "8px 12px", border: "1px solid #ccc", borderRadius: 4,
fontSize: 14, boxShadow: "0 2px 6px rgba(0,0,0,0.2)", background: "white",
width: "100%", padding: "8px 12px", border: "1px solid var(--border)",
fontSize: 14, background: "var(--panel)", color: "var(--text)",
}}
/>
{showResults && results.length > 0 && <div style={{
background: "white", border: "1px solid #ccc", borderTop: "none",
borderRadius: "0 0 4px 4px", maxHeight: 300, overflowY: "auto",
boxShadow: "0 4px 8px rgba(0,0,0,0.2)",
background: "var(--panel)", border: "1px solid var(--border)", borderTop: "none",
maxHeight: 300, overflowY: "auto",
}}>
{results.map((r, i) => <div
key={i}
onClick={() => handleSelect(r)}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
style={{padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid #eee"}}
onMouseEnter={e => e.currentTarget.style.background = "var(--head)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
style={{padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid var(--border)"}}
>
<div style={{fontWeight: 500}}>{r.name}</div>
{resultSubtext(r) && <div style={{fontSize: 11, color: "#888"}}>{resultSubtext(r)}</div>}
{resultSubtext(r) && <div style={{fontSize: 11, color: "var(--muted)"}}>{resultSubtext(r)}</div>}
</div>)}
</div>}
</div>;
@ -906,12 +910,12 @@ export default function MapViewer() {
}, [addingPin, loadPins]);
if (error) {
return <Segment>
return <Panel>
<Header as="h3">Map failed to load</Header>
<p>Your browser may not support WebGL. You can access the standalone map viewer
at <a href={MAP_VIEWER_URI}>{MAP_VIEWER_URI}</a></p>
<p>Error: {error}</p>
</Segment>;
</Panel>;
}
return <div ref={mapContainer} style={{position: "relative", width: "100%", height: "85vh"}}>

View file

@ -9,9 +9,8 @@ import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEsti
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
import {AccordionContent, AccordionTitle, Grid, GridColumn, GridRow, Header as SHeader, Label} from "semantic-ui-react";
import {Accordion, Header, Icon, Loader, Modal, Segment} from "./Theme";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
import {QueryContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
const SUGGESTED_APPS = [
@ -460,7 +459,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
if (description) {
return <>
<SHeader as='h4'>{title}</SHeader>
<Header as='h4'>{title}</Header>
{description}
</>
}
@ -526,56 +525,38 @@ export function SearchIconButton() {
}
function SearchChannelPreview({channel}) {
const {t} = React.useContext(ThemeContext);
return <GridRow {...t}>
<GridColumn>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</GridColumn>
</GridRow>
return <div>
<Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>
</div>
}
function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
const [activeIndex, setActiveIndex] = React.useState(0);
const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
const handleClick = (newIndex) => {
console.log('newIndex', newIndex, 'activeIndex', activeIndex);
setActiveIndex(activeIndex === newIndex ? null : newIndex);
}
if (loading || channelsLoading) {
return <Accordion>
<Segment><Loader active/></Segment>
</Accordion>
return <Panel><Loading/></Panel>
}
const channelsAccordion = <React.Fragment>
<AccordionTitle
index={0}
active={activeIndex === 0}
onClick={() => handleClick(0)}
>
<Header as='h3'>
<Icon name='dropdown'/>
Channels
<Label>{normalizeEstimate(channels.length)}</Label>
</Header>
</AccordionTitle>
<AccordionContent active={activeIndex === 0}>
<Grid>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview channel={i}/>)
: 'No Channels'}
</Grid>
</AccordionContent>
</React.Fragment>;
return <Accordion>
{channelsAccordion}
return <Accordion value={activeValue} onChange={setActiveValue}>
<Accordion.Item value='channels'>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>Channels</Header>
<Label>{normalizeEstimate(channels.length)}</Label>
</span>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='xs'>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
: 'No Channels'}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
}
}

View file

@ -64,12 +64,12 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('3')).toBeInTheDocument();
});
it('opens the modal and only shows the sections enabled by props', () => {
it('opens the modal and only shows the sections enabled by props', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
// Enabled sections.
expect(screen.getByText('Sort By')).toBeInTheDocument();
expect(await screen.findByText('Sort By')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Results Per Page')).toBeInTheDocument();
@ -79,13 +79,13 @@ describe('SearchFilterButton', () => {
expect(screen.queryByText('From Year')).toBeNull();
});
it('applies changes only on close, not as fields change', () => {
it('applies changes only on close, not as fields change', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
// Changing the sort field updates the draft but must NOT perform the search yet.
fireEvent.click(screen.getByText('Size'));
fireEvent.click(await screen.findByText('Size'));
expect(spy).not.toHaveBeenCalled();
// Closing via Done commits the draft in a single query update.
@ -94,25 +94,25 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({order: '-size'});
});
it('Clear All resets results-per-page to the default on close', () => {
it('Clear All resets results-per-page to the default on close', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {l: '96'}, spy);
fireEvent.click(screen.getByText('Filter'));
fireEvent.click(screen.getByText('Clear All'));
fireEvent.click(await screen.findByText('Clear All'));
fireEvent.click(screen.getByText('Done'));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchObject({l: 24});
});
it('does not update the query when closed without changes', () => {
it('does not update the query when closed without changes', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders}, {}, spy);
fireEvent.click(screen.getByText('Filter'));
fireEvent.click(screen.getByText('Done'));
fireEvent.click(await screen.findByText('Done'));
expect(spy).not.toHaveBeenCalled();
});
it('counts the censored filter and applies it on close when showCensored is set', () => {
it('counts the censored filter and applies it on close when showCensored is set', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders, showCensored: true}, {}, spy);
@ -121,7 +121,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Availability section/toggle is shown.
expect(screen.getByText('Availability')).toBeInTheDocument();
expect(await screen.findByText('Availability')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@ -131,9 +131,10 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({censored: 'true'});
});
it('does not show the Availability section unless showCensored is set', () => {
it('does not show the Availability section unless showCensored is set', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
await screen.findByText('Sort By');
expect(screen.queryByText('Availability')).toBeNull();
});
@ -142,7 +143,7 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('1')).toBeInTheDocument();
});
it('counts the deep filter and applies it on close when showDeep is set', () => {
it('counts the deep filter and applies it on close when showDeep is set', async () => {
const spy = jest.fn();
renderButton({sorts: videoOrders, showDeep: true}, {}, spy);
@ -151,7 +152,7 @@ describe('SearchFilterButton', () => {
fireEvent.click(screen.getByText('Filter'));
// The Search Depth section/toggle is shown.
expect(screen.getByText('Search Depth')).toBeInTheDocument();
expect(await screen.findByText('Search Depth')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
@ -161,9 +162,10 @@ describe('SearchFilterButton', () => {
expect(spy.mock.calls[0][0]).toMatchObject({deep: 'true'});
});
it('does not show the Search Depth section unless showDeep is set', () => {
it('does not show the Search Depth section unless showDeep is set', async () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
await screen.findByText('Sort By');
expect(screen.queryByText('Search Depth')).toBeNull();
});
@ -172,13 +174,13 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('1')).toBeInTheDocument();
});
it('shows the File Type section when fileFilterOptions are provided', () => {
it('shows the File Type section when fileFilterOptions are provided', async () => {
renderButton({
sorts: videoOrders,
fileFilterOptions: [{value: 'pdf', text: 'PDF'}, {value: 'epub', text: 'EPUB'}],
});
fireEvent.click(screen.getByText('Filter'));
expect(screen.getByText('File Type')).toBeInTheDocument();
expect(await screen.findByText('File Type')).toBeInTheDocument();
expect(screen.getByText('PDF')).toBeInTheDocument();
expect(screen.getByText('EPUB')).toBeInTheDocument();
});

View file

@ -1,4 +1,4 @@
import React, {useContext, useEffect, useRef, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {Link, Outlet, useParams} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {
@ -15,7 +15,6 @@ import {
HelpHeader,
InfoHeader,
isoDatetimeToAgoPopup,
mimetypeColor,
PageContainer,
PreviewLink,
scrollToTop,
@ -27,28 +26,25 @@ import {
} from "./Common"
import VideoPage from "./VideoPlayer";
import {
CardContent,
CardDescription,
CardHeader,
Container,
Form,
FormDropdown,
FormInput,
FormField,
TextArea,
Image,
Label,
PlaceholderHeader,
PlaceholderLine,
StatisticLabel,
StatisticValue,
TableCell
} from "semantic-ui-react";
ActionInput,
Button,
Card,
Header,
Icon,
Loading,
Modal,
NumberInput,
Panel,
Placeholder,
Statistic,
StatisticGroup,
Table,
Textarea,
TextInput,
Tooltip,
} from "./ui";
import {useChannel, useSearchOrder, useSearchVideos, useVideo, useVideoStatistics} from "../hooks/customHooks";
import {DeepSearchHint, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statistic} from "./Theme";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {
@ -63,7 +59,7 @@ import {
updateVideoDownloaderConfig,
uploadCookies,
} from "../api";
import {QueryContext, ThemeContext} from "../contexts/contexts";
import {QueryContext} from "../contexts/contexts";
import _ from "lodash";
import {defaultFileOrder, defaultSearchOrder} from "./Vars";
import {InputForm, ToggleForm, useForm} from "../hooks/useForm";
@ -143,11 +139,7 @@ export function VideosPage() {
</Header>
</>;
} else if (channelId) {
header = <Placeholder style={{marginBottom: '1em'}}>
<PlaceholderHeader>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
header = <div style={{marginBottom: '1em'}}><Placeholder lines={1}/></div>;
}
const onSelect = (path, checked) => {
@ -184,26 +176,26 @@ export function VideosPage() {
const selectElm = <div style={{marginTop: '0.5em'}}>
<Button
color='violet'
role='primary'
disabled={_.isEmpty(selectedVideos)}
onClick={() => setBulkTagOpen(true)}
>Tag</Button>
<APIButton
color='red'
role='danger'
disabled={_.isEmpty(selectedVideos)}
confirmButton='Delete'
confirmContent='Are you sure you want to delete these video files? This cannot be undone.'
onClick={() => onDelete(false)}
>Delete</APIButton>
<Button
color='grey'
role='cancel'
onClick={() => invertSelection()}
disabled={_.isEmpty(videos)}
>
Invert
</Button>
<Button
color='yellow'
role='cancel'
onClick={() => clearSelection()}
disabled={_.isEmpty(videos) || _.isEmpty(selectedVideos)}
>
@ -284,7 +276,6 @@ function VideoFileNameForm({form}) {
const label = <InfoHeader
headerSize='h5'
headerContent='Video File Format'
popupProps={{wide: 'very', position: 'top left'}}
popupContent={<>
<p>Common yt-dlp variables:</p>
<ul>
@ -313,6 +304,13 @@ function VideoFileNameForm({form}) {
/>
}
const settingsGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '1em',
marginBottom: '1em',
};
export function VideosSettingsPage() {
useTitle('Videos Settings');
@ -380,170 +378,140 @@ export function VideosSettingsPage() {
/>;
return <>
<Segment>
<Panel>
<Header as='h3'>Video Downloader Config</Header>
<Form>
<Grid>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<VideoResolutionSelectorForm
form={configForm}
name='video_resolutions'
path='video_resolutions'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<VideoFormatSelectorForm
form={configForm}
name='merge_output_format'
path='yt_dlp_options.merge_output_format'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<VideoFileNameForm form={configForm}/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Do not overwrite existing files'
name='nooverwrites'
path='yt_dlp_options.nooverwrites'
icon='file video'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download automatic subtitles'
name='writeautomaticsub'
path='yt_dlp_options.writeautomaticsub'
icon='closed captioning'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download subtitles'
name='writesubtitles'
path='yt_dlp_options.writesubtitles'
icon='closed captioning outline'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download thumbnail'
name='writethumbnail'
path='yt_dlp_options.writethumbnail'
icon='image'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={2}>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Download info JSON'
name='writeinfojson'
path='yt_dlp_options.writeinfojson'
icon='file code'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label='Continue partial downloads'
name='continue_dl'
path='yt_dlp_options.continue_dl'
icon='play'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={8}>
<ToggleForm
form={configForm}
label={downloadMissingVideoCommentsLabel}
name='download_missing_video_comments'
path='download_missing_video_comments'
icon='comments'
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={8}>
<InputForm
form={configForm}
name='yt_dlp_extra_args'
path='yt_dlp_extra_args'
label='Extra yt-dlp Arguments'
placeholder='--prefer-free-formats'
icon='terminal'
/>
</Grid.Column>
<Grid.Column mobile={16} computer={4}>
<Form.Field>
<InfoHeader
headerSize='h5'
headerContent='Sleep Between Requests'
popupContent='Seconds to sleep between yt-dlp API requests. Helps avoid rate limiting and bot detection. Set to 0 to disable. Does not affect video download speed.'
/>
<FormInput
type='number'
step='0.25'
min='0'
max='10'
value={configForm.formData.sleep_requests ?? 0.75}
onChange={(e) => configForm.setValue('sleep_requests', parseFloat(e.target.value) || 0)}
placeholder='0.75'
label={{basic: true, content: 'seconds'}}
labelPosition='right'
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column mobile={16} computer={16}>
<Form.Field>
<InfoHeader
headerSize='h5'
headerContent='User Agent'
popupContent='Custom user-agent string for yt-dlp. Should match the browser used to export cookies. Click "Use Current Browser" to auto-fill with your browser&apos;s user-agent.'
/>
<FormInput
value={configForm.formData.user_agent || ''}
onChange={(e) => configForm.setValue('user_agent', e.target.value)}
placeholder='Leave empty to use yt-dlp default'
action={{
content: 'Use Current Browser',
onClick: () => configForm.setValue('user_agent', suggestedUserAgent),
}}
/>
</Form.Field>
</Grid.Column>
</Grid.Row>
<form onSubmit={e => e.preventDefault()}>
<div style={settingsGridStyle}>
<VideoResolutionSelectorForm
form={configForm}
name='video_resolutions'
path='video_resolutions'
/>
<VideoFormatSelectorForm
form={configForm}
name='merge_output_format'
path='yt_dlp_options.merge_output_format'
/>
<VideoFileNameForm form={configForm}/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Do not overwrite existing files'
name='nooverwrites'
path='yt_dlp_options.nooverwrites'
icon='file video'
/>
<ToggleForm
form={configForm}
label='Download automatic subtitles'
name='writeautomaticsub'
path='yt_dlp_options.writeautomaticsub'
icon='closed captioning'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Download subtitles'
name='writesubtitles'
path='yt_dlp_options.writesubtitles'
icon='closed captioning outline'
/>
<ToggleForm
form={configForm}
label='Download thumbnail'
name='writethumbnail'
path='yt_dlp_options.writethumbnail'
icon='image'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label='Download info JSON'
name='writeinfojson'
path='yt_dlp_options.writeinfojson'
icon='file code'
/>
<ToggleForm
form={configForm}
label='Continue partial downloads'
name='continue_dl'
path='yt_dlp_options.continue_dl'
icon='play'
/>
</div>
<div style={settingsGridStyle}>
<ToggleForm
form={configForm}
label={downloadMissingVideoCommentsLabel}
name='download_missing_video_comments'
path='download_missing_video_comments'
icon='comments'
/>
</div>
<div style={settingsGridStyle}>
<InputForm
form={configForm}
name='yt_dlp_extra_args'
path='yt_dlp_extra_args'
label='Extra yt-dlp Arguments'
placeholder='--prefer-free-formats'
icon='terminal'
/>
<div>
<InfoHeader
headerSize='h5'
headerContent='Sleep Between Requests'
popupContent='Seconds to sleep between yt-dlp API requests. Helps avoid rate limiting and bot detection. Set to 0 to disable. Does not affect video download speed.'
/>
<NumberInput
step={0.25}
min={0}
max={10}
value={configForm.formData.sleep_requests ?? 0.75}
onChange={(value) => configForm.setValue('sleep_requests', parseFloat(value) || 0)}
placeholder='0.75'
rightSection={<span style={{fontSize: 12, color: 'var(--muted)'}}>sec</span>}
rightSectionWidth={36}
/>
</div>
</div>
<div style={settingsGridStyle}>
<div style={{gridColumn: '1 / -1'}}>
<InfoHeader
headerSize='h5'
headerContent='User Agent'
popupContent="Custom user-agent string for yt-dlp. Should match the browser used to export cookies. Click &quot;Use Current Browser&quot; to auto-fill with your browser's user-agent."
/>
<ActionInput
value={configForm.formData.user_agent || ''}
onChange={(e) => configForm.setValue('user_agent', e.currentTarget.value)}
placeholder='Leave empty to use yt-dlp default'
action={<Button role='cancel'
onClick={() => configForm.setValue('user_agent', suggestedUserAgent)}>
Use Current Browser
</Button>}
/>
</div>
</div>
<Grid.Row columns={1}>
<Grid.Column textAlign='right'>
<APIButton
disabled={configForm.disabled || !configForm.ready}
type='submit'
style={{marginTop: '0.5em'}}
onClick={configForm.onSubmit}
id='video_settings_save_button'
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>
<div style={{textAlign: 'right'}}>
<APIButton
role='primary'
disabled={configForm.disabled || !configForm.ready}
type='submit'
style={{marginTop: '0.5em'}}
onClick={configForm.onSubmit}
id='video_settings_save_button'
>Save</APIButton>
</div>
</form>
</Panel>
<Segment>
<Panel>
<Header as='h4'>File Organization</Header>
<p>
{fetchingReorgCount
@ -557,15 +525,16 @@ export function VideosSettingsPage() {
}
</p>
<Button
color='orange'
role='retry'
icon='folder open outline'
onClick={() => setBatchModalOpen(true)}
id='reorganize_all_channels_button'
disabled={fetchingReorgCount || channelsNeedingReorg === 0}
loading={fetchingReorgCount}
>
<Icon name='folder open outline'/> Reorganize All Channels
Reorganize All Channels
</Button>
</Segment>
</Panel>
<CookiesSettingsSection/>
@ -644,14 +613,16 @@ function CookiesSettingsSection() {
fetchStatus();
};
// Status is encoded by color like any other status label; no filled "success/warning"
// words map directly to a token, so the wrapper picks the token color explicitly.
const getStatusLabel = () => {
if (loading) return <Label color='grey'>Loading...</Label>;
if (!status.cookies_exist) return <Label color='grey'>No cookies stored</Label>;
if (status.cookies_unlocked) return <Label color='green'>Unlocked</Label>;
return <Label color='yellow'>Locked</Label>;
if (loading) return <span style={{color: 'var(--muted)'}}>Loading...</span>;
if (!status.cookies_exist) return <span style={{color: 'var(--muted)'}}>No cookies stored</span>;
if (status.cookies_unlocked) return <span style={{color: 'var(--green)'}}>Unlocked</span>;
return <span style={{color: 'var(--amber)'}}>Locked</span>;
};
return <Segment>
return <Panel>
<HelpHeader
headerSize='h4'
headerContent='Encrypted Cookies'
@ -665,59 +636,64 @@ function CookiesSettingsSection() {
<strong>Status:</strong> {getStatusLabel()}
</div>
<Button.Group>
<div style={{display: 'flex', gap: '0.5em', flexWrap: 'wrap'}}>
{!status.cookies_exist && (
<Button
color='blue'
role='primary'
icon='upload'
onClick={() => setUploadModalOpen(true)}
disabled={loading}
>
<Icon name='upload'/> Upload Cookies
Upload Cookies
</Button>
)}
{status.cookies_exist && !status.cookies_unlocked && (
<Button
color='green'
role='save'
icon='unlock'
onClick={() => setUnlockModalOpen(true)}
disabled={loading}
>
<Icon name='unlock'/> Unlock Cookies
Unlock Cookies
</Button>
)}
{status.cookies_exist && status.cookies_unlocked && (
<Button
color='yellow'
role='retry'
icon='lock'
onClick={handleLock}
loading={submitting}
disabled={loading || submitting}
>
<Icon name='lock'/> Lock Cookies
Lock Cookies
</Button>
)}
{status.cookies_exist && (
<>
<Button
color='blue'
role='primary'
icon='refresh'
onClick={() => setUploadModalOpen(true)}
disabled={loading}
>
<Icon name='refresh'/> Replace
Replace
</Button>
<APIButton
color='red'
role='danger'
icon='trash'
onClick={handleDelete}
confirmButton='Delete'
confirmContent='Are you sure you want to delete the stored cookies? This cannot be undone.'
disabled={loading || submitting}
>
<Icon name='trash'/> Delete
Delete
</APIButton>
</>
)}
</Button.Group>
</div>
{/* Upload Modal */}
<Modal
@ -727,39 +703,41 @@ function CookiesSettingsSection() {
>
<Modal.Header>Upload Cookies</Modal.Header>
<Modal.Content>
<Form>
<FormField>
<label>Cookies Content</label>
<TextArea
ref={cookiesContentRef}
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.target.value)}
rows={10}
/>
</FormField>
<FormInput
label='Encryption Password'
type='password'
placeholder='Minimum 8 characters'
value={password}
onChange={(e) => setPassword(e.target.value)}
error={password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : null}
<form onSubmit={e => e.preventDefault()}>
<Textarea
ref={cookiesContentRef}
label='Cookies Content'
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.currentTarget.value)}
minRows={10}
/>
<p style={{color: 'grey', fontSize: '0.9em'}}>
<div style={{marginTop: '1em'}}>
<TextInput
label='Encryption Password'
type='password'
placeholder='Minimum 8 characters'
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
error={password.length > 0 && password.length < 8
? 'Password must be at least 8 characters' : null}
/>
</div>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
Remember this password - you'll need it to unlock cookies after each restart.
</p>
</Form>
</form>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setUploadModalOpen(false)}>Cancel</Button>
<Button role='cancel' onClick={() => setUploadModalOpen(false)}>Cancel</Button>
<Button
color='blue'
role='save'
icon='lock'
onClick={handleUpload}
loading={submitting}
disabled={!cookiesContent || password.length < 8 || submitting}
>
<Icon name='lock'/> Encrypt & Save
Encrypt & Save
</Button>
</Modal.Actions>
</Modal>
@ -769,7 +747,7 @@ function CookiesSettingsSection() {
onClose={() => setUnlockModalOpen(false)}
onSuccess={fetchStatus}
/>
</Segment>;
</Panel>;
}
export function VideosStatistics() {
@ -779,7 +757,7 @@ export function VideosStatistics() {
if (statistics === null) {
// Request is pending.
return <Loader active inline='centered'/>
return <Loading/>
} else if (statistics === undefined) {
return <ErrorMessage>Unable to fetch Video Statistics</ErrorMessage>
}
@ -810,26 +788,23 @@ export function VideosStatistics() {
{key: 'tagged_channels', label: 'Tagged Channels'},
];
const buildSegment = (title, names, stats) => {
return <Segment>
<Header textAlign='center' as='h1'>{title}</Header>
<Statistic.Group>
const buildPanel = (title, names, stats) => {
return <Panel>
<Header as='h1' style={{textAlign: 'center'}}>{title}</Header>
<StatisticGroup>
{names.map(
({key, label}) =>
<Statistic key={key} style={{margin: '2em'}}>
<StatisticValue>{stats[key]}</StatisticValue>
<StatisticLabel>{label}</StatisticLabel>
</Statistic>
<Statistic key={key} value={stats[key]} label={label} style={{margin: '2em'}}/>
)}
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
}
return <>
{buildSegment('Videos', videoNames, videos)}
{buildSegment('Video Comments', commentsNames, videos)}
{buildSegment('Historical Video', historicalNames, historical)}
{buildSegment('Channels', channelNames, channels)}
{buildPanel('Videos', videoNames, videos)}
{buildPanel('Video Comments', commentsNames, videos)}
{buildPanel('Historical Video', historicalNames, historical)}
{buildPanel('Channels', channelNames, channels)}
</>
}
@ -849,7 +824,6 @@ export function VideosTabLayout() {
export function VideoCard({file}) {
const {video} = file;
const {s} = useContext(ThemeContext);
const {sort} = useSearchOrder();
const sortField = sort ? sort.replace(/^-/, '') : null;
@ -863,53 +837,50 @@ export function VideoCard({file}) {
channel_url = `/videos/channel/${channel.id}/video`;
}
let poster = <CardPoster to={video_url} file={file}/>;
const media = <div style={{position: 'relative'}}>
<CardPoster to={video_url} file={file}/>
<Duration totalSeconds={file.length}/>
</div>;
const title = file.title || file.name || video.stem || video.name;
let header = <span {...s} className='card-title-ellipsis'>{title}</span>;
let titleElm = <Tooltip label={title}>
<span className='card-title-ellipsis'>{title}</span>
</Tooltip>;
if (video_url) {
// Link to Channel-Video page or Video page.
header = <Link to={video_url} className="no-link-underscore card-link">{header}</Link>;
titleElm = <Link to={video_url} className="no-link-underscore card-link">{titleElm}</Link>;
} else {
// Video is just a lone video file.
header = <PreviewLink file={file}>
{header}
titleElm = <PreviewLink file={file}>
{titleElm}
</PreviewLink>;
}
const color = mimetypeColor(file.mimetype);
return <Card color={color}>
{poster}
<Duration totalSeconds={file.length}/>
<CardContent {...s}>
<CardHeader>
<Container textAlign='left'>
<Popup on='hover'
trigger={header}
content={title}/>
</Container>
</CardHeader>
<CardDescription>
<Container textAlign='left'>
{channel && <Link to={channel_url} className="no-link-underscore card-link">
<b {...s}>{channel.name}</b>
</Link>}
{sortField === 'length'
? <p {...s}>{secondsToFullDuration(file.length || 0)}</p>
: sortField === 'size'
? <p {...s}>{humanFileSize(file.size)}</p>
: sortField === 'view_count'
? <p {...s}>{humanNumber(video.view_count || 0)} views</p>
: sortField === 'viewed'
? <p {...s}>{isoDatetimeToAgoPopup(file.viewed, false)}</p>
: sortField === 'download_datetime'
? <p {...s}>{isoDatetimeToAgoPopup(file.download_datetime, false)}</p>
: <p {...s}>{isoDatetimeToAgoPopup(file.published_datetime, false)}</p>
}
</Container>
</CardDescription>
</CardContent>
</Card>
let detailLine;
if (sortField === 'length') {
detailLine = secondsToFullDuration(file.length || 0);
} else if (sortField === 'size') {
detailLine = humanFileSize(file.size);
} else if (sortField === 'view_count') {
detailLine = `${humanNumber(video.view_count || 0)} views`;
} else if (sortField === 'viewed') {
detailLine = isoDatetimeToAgoPopup(file.viewed, false);
} else if (sortField === 'download_datetime') {
detailLine = isoDatetimeToAgoPopup(file.download_datetime, false);
} else {
detailLine = isoDatetimeToAgoPopup(file.published_datetime, false);
}
const meta = <>
{channel && <div>
<Link to={channel_url} className="no-link-underscore card-link">
<b>{channel.name}</b>
</Link>
</div>}
<div>{detailLine}</div>
</>;
return <Card media={media} title={titleElm} meta={meta}/>
}
export function VideoRowCells({file}) {
@ -924,10 +895,7 @@ export function VideoRowCells({file}) {
let poster;
if (poster_url) {
poster = <CardLink to={video_url}>
<Image wrapped
src={poster_url}
width='50px'
/>
<img alt='' src={poster_url} style={{width: '50px', height: 'auto'}}/>
</CardLink>
} else {
poster = <FileIcon file={file} size='large'/>;
@ -946,17 +914,17 @@ export function VideoRowCells({file}) {
// Fragment for SelectableRow
return <React.Fragment>
<TableCell>
<center>
<Table.Cell>
<div style={{textAlign: 'center'}}>
{poster}
</center>
</TableCell>
<TableCell>
</div>
</Table.Cell>
<Table.Cell>
<CardLink to={video_url}>
<FileRowTagIcon file={file}/>
{textEllipsis(file.title || video.stem || video.video_path)}
</CardLink>
</TableCell>
<TableCell>{dataCell}</TableCell>
</Table.Cell>
<Table.Cell>{dataCell}</Table.Cell>
</React.Fragment>
}

View file

@ -2,18 +2,6 @@ import React from "react";
import {SettingsContext, StatusContext} from "../../contexts/contexts";
import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api";
import {getServices, startService, stopService} from "../../api/controller";
import {Button, Divider, Form, Header, Loader, Modal, Segment} from "../Theme";
import {
ButtonGroup,
Container,
Dimmer,
Dropdown,
FormDropdown,
GridColumn,
GridRow,
Icon,
Input
} from "semantic-ui-react";
import {
APIButton,
ErrorMessage,
@ -25,15 +13,26 @@ import {
WROLModeMessage
} from "../Common";
import {useConfigs, useDockerized} from "../../hooks/customHooks";
import {toast} from "react-semantic-toasts-2";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {ConfigsTable} from "./Configs";
import {semanticUIColorMap} from "../Vars";
import {Link} from "react-router";
import {ThemePicker} from "../ui";
import {
Button,
Divider,
Grid,
Header,
Icon,
Loading,
Modal,
Panel,
Select,
ThemePicker,
toast,
TextInput,
} from "../ui";
function ThemeSegment() {
return <Segment>
return <Panel>
<Header as='h2'>Theme</Header>
<p>Choose how WROLPi looks. The choice is saved in this browser, so each device can
differ.</p>
@ -45,9 +44,9 @@ function ThemeSegment() {
<Header as='h3'>Preview</Header>
<p>See every part of the interface in the theme you picked.</p>
<Link to='/theme-sample'>
<Button><Icon name='eye'/> View the components</Button>
<Button icon='eye'>View the components</Button>
</Link>
</Segment>;
</Panel>;
}
export function ShutdownButton() {
@ -91,7 +90,7 @@ export function ShutdownButton() {
return <APIButton
size='huge'
color='red'
role='danger'
onClick={handleShutdown}
confirmContent='Are you sure you want to turn off your WROLPi?'
confirmButton='Shutdown'
@ -138,33 +137,32 @@ function UpgradeSegment() {
// Not available in Docker
if (dockerized) {
return <Segment id='upgrade'>
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<p>Upgrades are not available in Docker environments. Please upgrade your Docker images manually.</p>
</Segment>;
</Panel>;
}
// No update available
if (!status?.update_available) {
return <Segment id='upgrade'>
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<p>Your WROLPi is up to date.</p>
<p>Version: <strong>v{status?.version}</strong> on branch <strong>{status?.git_branch || 'unknown'}</strong>
</p>
<APIButton
color='blue'
role='primary'
onClick={handleCheckUpgrade}
disabled={checking}
>
{checking ? 'Checking...' : 'Check for Upgrades'}
</APIButton>
</Segment>;
</Panel>;
}
// Update available
return <Segment id='upgrade'>
<Header as='h3'>
<Icon name='arrow alternate circle up'/>
return <Panel id='upgrade'>
<Header as='h3' icon='arrow alternate circle up'>
Upgrade Available
</Header>
<p>Branch: <strong>{status?.git_branch}</strong></p>
@ -173,7 +171,7 @@ function UpgradeSegment() {
<p><strong>{status?.commits_behind}</strong> commit(s) behind</p>
<APIButton
color='green'
role='save'
size='big'
onClick={handleUpgrade}
disabled={upgrading}
@ -182,7 +180,7 @@ function UpgradeSegment() {
>
{upgrading ? 'Starting Upgrade...' : 'Upgrade Now'}
</APIButton>
</Segment>;
</Panel>;
}
export function RestartButton() {
@ -226,7 +224,7 @@ export function RestartButton() {
return <APIButton
size='huge'
color='yellow'
role='retry'
onClick={handleRestart}
confirmContent='Are you sure you want to reboot your WROLPi?'
confirmButton='Restart'
@ -290,16 +288,15 @@ function WROLModeSection() {
};
return (
<Segment>
<Header as='h3'>
<Icon name='shield'/>
<Panel danger>
<Header as='h3' icon='shield'>
WROL Mode
</Header>
<p>
Enable read-only mode. No content can be deleted or modified.
Enable this when the SHTF and you want to prevent any potential loss of data.
</p>
<p style={{fontSize: '0.9em', color: '#888'}}>
<p style={{fontSize: '0.9em', color: 'var(--muted)'}}>
Note: User settings and tags can still be modified.
</p>
<Toggle
@ -307,7 +304,7 @@ function WROLModeSection() {
onChange={toggleWROLMode}
label={wrolMode ? 'WROL Mode Enabled' : 'WROL Mode Disabled'}
/>
</Segment>
</Panel>
);
}
@ -494,28 +491,22 @@ export function SettingsPage() {
});
}, [JSON.stringify(settings)]);
const handleInputChange = async (e, name, value) => {
if (e) {
e.preventDefault()
}
const handleInputChange = async (name, value) => {
setState({...state, [name]: value});
}
const handleTimeoutChange = async (e, name, value) => {
if (e) {
e.preventDefault()
}
const handleTimeoutChange = async (name, value) => {
// Restrict timeout to numbers.
value = value.replace(/[^\d]/, '');
setState({...state, [name]: value});
}
const timezoneOptions = Intl.supportedValuesOf('timeZone').map(tz => (
{key: tz, value: tz, text: tz}
{value: tz, label: tz}
));
const navColorOptions = Object.keys(semanticUIColorMap).map(i => {
return {key: i, value: i, text: i.charAt(0).toUpperCase() + i.slice(1)}
return {value: i, label: i.charAt(0).toUpperCase() + i.slice(1)}
});
const mediaDirectoryLabel = `${settings.media_directory}/`;
@ -526,94 +517,92 @@ export function SettingsPage() {
body = <>
<p>Any changes will be written to <i>{settings.media_directory}/config/wrolpi.yaml</i>.</p>
<Form id="settings">
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column>
<div style={{margin: '0.5em'}}>
<Toggle
label='CPU Power-save on Startup'
disabled={disabled || state.throttle_on_startup === null}
checked={state.throttle_on_startup === true}
onChange={checked => handleInputChange(null, 'throttle_on_startup', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Ignore outdated Zims'
disabled={disabled || state.ignore_outdated_zims === null}
checked={state.ignore_outdated_zims === true}
onChange={checked => handleInputChange(null, 'ignore_outdated_zims', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Check for upgrades hourly'
disabled={disabled || dockerized || state.check_for_upgrades === null}
checked={!dockerized && state.check_for_upgrades === true}
onChange={checked => handleInputChange(null, 'check_for_upgrades', checked)}
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Tags Directory'
disabled={disabled || state.tags_directory === null}
checked={state.tags_directory === true}
onChange={checked => handleInputChange(null, 'tags_directory', checked)}
info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Save FFprobe Cache Files'
disabled={disabled || state.save_ffprobe_json === null}
checked={state.save_ffprobe_json === true}
onChange={checked => handleInputChange(null, 'save_ffprobe_json', checked)}
info='When enabled, FFprobe results are saved as .ffprobe.json files alongside videos. This speeds up reindexing but uses additional disk space (~5KB per video).'
/>
</div>
<br/>
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
<form id="settings" onSubmit={e => e.preventDefault()}>
<Grid>
<Grid.Col span={12}>
<div style={{margin: '0.5em'}}>
<Toggle
label='CPU Power-save on Startup'
disabled={disabled || state.throttle_on_startup === null}
checked={state.throttle_on_startup === true}
onChange={checked => handleInputChange('throttle_on_startup', checked)}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
</div>
<br/>
<div style={{margin: '0.5em'}}>
<Toggle
label='Ignore outdated Zims'
disabled={disabled || state.ignore_outdated_zims === null}
checked={state.ignore_outdated_zims === true}
onChange={checked => handleInputChange('ignore_outdated_zims', checked)}
/>
</div>
<ButtonGroup>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Dropdown
id='settings_navbar_color_dropdown'
className='button icon'
floating
options={navColorOptions}
onChange={(e, {value}) => setState({...state, nav_color: value})}
value={state.nav_color}
/>
</ButtonGroup>
<div style={{margin: '0.5em'}}>
<Toggle
label='Check for upgrades hourly'
disabled={disabled || dockerized || state.check_for_upgrades === null}
checked={!dockerized && state.check_for_upgrades === true}
onChange={checked => handleInputChange('check_for_upgrades', checked)}
/>
</div>
</Grid.Column>
</Grid.Row>
<div style={{margin: '0.5em'}}>
<Toggle
label='Tags Directory'
disabled={disabled || state.tags_directory === null}
checked={state.tags_directory === true}
onChange={checked => handleInputChange('tags_directory', checked)}
info='When enabled, WROLPi creates a "tags" directory with hardlinks to all tagged files, organized by tag name.'
/>
</div>
<div style={{margin: '0.5em'}}>
<Toggle
label='Save FFprobe Cache Files'
disabled={disabled || state.save_ffprobe_json === null}
checked={state.save_ffprobe_json === true}
onChange={checked => handleInputChange('save_ffprobe_json', checked)}
info='When enabled, FFprobe results are saved as .ffprobe.json files alongside videos. This speeds up reindexing but uses additional disk space (~5KB per video).'
/>
</div>
<br/>
<label htmlFor='log_levels_input'>Log Level: {logLevelToName(state.log_level)}</label>
<br/>
<input type='range'
id='log_levels_input'
list='log_levels'
min='1'
max='5'
value={state.log_level}
onChange={e => setState({...state, log_level: parseInt(e.target.value)})}
style={{marginBottom: '1em'}}
/>
<datalist id='log_levels'>
<option value='1'>Critical</option>
<option value='2'>Warning</option>
<option value='3'>Info</option>
<option value='4'>Debug</option>
<option value='5'>Trace</option>
</datalist>
<br/>
<div style={{display: 'flex', alignItems: 'center', gap: 0}}>
<Button color={state.nav_color} onClick={e => e.preventDefault()}>Navbar Color</Button>
<Select
id='settings_navbar_color_dropdown'
data={navColorOptions}
value={state.nav_color || ''}
onChange={value => setState({...state, nav_color: value})}
allowDeselect={false}
style={{width: '10em'}}
/>
</div>
</Grid.Col>
</Grid>
@ -626,7 +615,7 @@ export function SettingsPage() {
label='Download on Startup'
disabled={disabled || state.download_on_startup === null}
checked={state.download_on_startup === true}
onChange={checked => handleInputChange(null, 'download_on_startup', checked)}
onChange={checked => handleInputChange('download_on_startup', checked)}
/>
</div>
@ -639,7 +628,7 @@ export function SettingsPage() {
</>}
disabled={disabled || state.require_cookies_unlocked === null}
checked={state.require_cookies_unlocked === true}
onChange={checked => handleInputChange(null, 'require_cookies_unlocked', checked)}
onChange={checked => handleInputChange('require_cookies_unlocked', checked)}
/>
</div>
@ -652,107 +641,112 @@ export function SettingsPage() {
</>}
disabled={disabled || state.require_media_mounted === null}
checked={state.require_media_mounted === true}
onChange={checked => handleInputChange(null, 'require_media_mounted', checked)}
onChange={checked => handleInputChange('require_media_mounted', checked)}
/>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Wait Between Downloads</b>
<InfoPopup
content='Number of seconds to wait after each download completes. This helps download "nicely" by being polite to servers. Set to 0 to disable waiting.'/>
</label>
<br/>
<TextInput
value={state.download_wait}
disabled={disabled || state.download_wait === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_wait', i.value)}
onChange={e => handleTimeoutChange('download_wait', e.currentTarget.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</>}
</div>
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Timeout</b>
<InfoPopup content='Downloads will be stopped after this many seconds have elapsed.
Downloads will never timeout if this is empty.'/>
</label>
<br/>
<TextInput
value={state.download_timeout}
disabled={disabled || state.download_timeout === null}
onChange={(e, i) => handleTimeoutChange(e, 'download_timeout', i.value)}
onChange={e => handleTimeoutChange('download_timeout', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (per domain)</b>
<InfoPopup
content='Maximum number of downloads from any single domain per day. Recurring
Channel/RSS catalog updates do not count; only the videos/archives they create
and one-off downloads count. Leave empty or set to 0 for no limit.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Daily Download Limit (per domain)</b>
<InfoPopup
content='Maximum number of downloads from any single domain per day. Recurring
Channel/RSS catalog updates do not count; only the videos/archives they create
and one-off downloads count. Leave empty or set to 0 for no limit.'/>
</label>
<br/>
<TextInput
value={state.download_daily_limit_per_domain}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_per_domain', i.value)}
onChange={e => handleTimeoutChange('download_daily_limit_per_domain', e.currentTarget.value)}
/>
</Form.Group>
<Form.Group inline>
<Form.Input
label={<>
<b>Daily Download Limit (total)</b>
<InfoPopup
content='Maximum number of downloads across all domains per day. Recurring
Channel/RSS catalog updates do not count. Leave empty or set to 0 for no limit.'/>
</>}
</div>
<div style={{margin: '0.5em 0'}}>
<label>
<b>Daily Download Limit (total)</b>
<InfoPopup
content='Maximum number of downloads across all domains per day. Recurring
Channel/RSS catalog updates do not count. Leave empty or set to 0 for no limit.'/>
</label>
<br/>
<TextInput
value={state.download_daily_limit_global}
disabled={disabled}
onChange={(e, i) => handleTimeoutChange(e, 'download_daily_limit_global', i.value)}
onChange={e => handleTimeoutChange('download_daily_limit_global', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window Start</b>
<InfoPopup
content='Start time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Window Start</b>
<InfoPopup
content='Start time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</label>
<br/>
<TextInput
type='time'
value={state.download_window_start}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_start', i.value)}
onChange={e => handleInputChange('download_window_start', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Group inline>
<Form.Input
label={<>
<b>Download Window End</b>
<InfoPopup
content='End time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</>}
<div style={{margin: '0.5em 0'}}>
<label>
<b>Download Window End</b>
<InfoPopup
content='End time for allowed download window (HH:MM, 24-hour). Leave both empty for no restriction.'/>
</label>
<br/>
<TextInput
type='time'
value={state.download_window_end}
disabled={disabled}
onChange={(e, i) => handleInputChange(e, 'download_window_end', i.value)}
onChange={e => handleInputChange('download_window_end', e.currentTarget.value)}
/>
</Form.Group>
</div>
<Form.Field width={3}>
<div style={{margin: '0.5em 0', maxWidth: '20em'}}>
<label><b>Timezone</b>
<InfoPopup
content='IANA timezone (e.g. America/Denver). Leave empty to use system timezone.'/>
</label>
<FormDropdown
<Select
placeholder='System default'
options={timezoneOptions}
data={timezoneOptions}
value={state.timezone || ''}
disabled={disabled}
search
selection
searchable
clearable
onChange={(e, {value}) => setState({...state, timezone: value || ''})}
onChange={value => setState({...state, timezone: value || ''})}
/>
</Form.Field>
</div>
{status && status.local_time &&
<p>Current local time: <b>{status.local_time.slice(0, 16).replace('T', ' ')}</b></p>}
@ -762,139 +756,124 @@ export function SettingsPage() {
<p>WROLPi will save files to these directories (any video files will be saved to
the <i>videos</i> by default, etc.).</p>
<Grid stackable>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>
Archive Directory
<InfoPopup
wide='very'
content={<>
<p>Variables:</p>
<ul>
<li><code>%(domain)s</code> - Domain name (e.g., "example.com")</li>
<li><code>%(domain_tag)s</code> - Tag name for the domain (empty if no
tag)
</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the domain name</li>
</ul>
</>}
position='top left'
/>
</label>
<Input
label={mediaDirectoryLabel}
value={state.archive_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'archive_destination', d.value)}
/>
</Form.Field>
</GridColumn>
<GridColumn>
<Form.Field>
<label>
Videos Directory
<InfoPopup
wide='very'
content={<>
<p>Variables:</p>
<ul>
<li><code>%(channel_name)s</code> - Channel name</li>
<li><code>%(channel_tag)s</code> - Tag name for the channel (empty if no
tag)
</li>
<li><code>%(channel_domain)s</code> - Domain from the channel URL</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the channel name</li>
</ul>
</>}
position='top left'
/>
</label>
<Input
label={mediaDirectoryLabel}
value={state.videos_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'videos_destination', d.value)}
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>Map Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.map_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'map_destination', d.value)}
/>
</Form.Field>
</GridColumn>
<GridColumn>
<Form.Field>
<label>Zims Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.zims_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'zims_destination', d.value)}
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={2}>
<GridColumn>
<Form.Field>
<label>Playlists Directory</label>
<Input
label={mediaDirectoryLabel}
value={state.playlists_destination}
disabled={!editSpecialDirectories}
onChange={(e, d) => handleInputChange(e, 'playlists_destination', d.value)}
/>
</Form.Field>
</GridColumn>
</GridRow>
<GridRow columns={1}>
<GridColumn>
<Toggle
label='Edit Directories'
disabled={disabled}
checked={editSpecialDirectories === true}
onChange={checked => setEditSpecialDirectories(checked)}
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<label>
Archive Directory
<InfoPopup
w={340}
content={<>
<p>Variables:</p>
<ul>
<li><code>%(domain)s</code> - Domain name (e.g., "example.com")</li>
<li><code>%(domain_tag)s</code> - Tag name for the domain (empty if no
tag)
</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the domain name</li>
</ul>
</>}
position='top'
/>
</GridColumn>
</GridRow>
</label>
<TextInput
leftSectionWidth='auto'
leftSection={<span style={{fontSize: '0.85em', paddingLeft: 4}}>{mediaDirectoryLabel}</span>}
value={state.archive_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('archive_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>
Videos Directory
<InfoPopup
w={340}
content={<>
<p>Variables:</p>
<ul>
<li><code>%(channel_name)s</code> - Channel name</li>
<li><code>%(channel_tag)s</code> - Tag name for the channel (empty if no
tag)
</li>
<li><code>%(channel_domain)s</code> - Domain from the channel URL</li>
<li><code>%(tag)s</code> - Alias for the tag name</li>
<li><code>%(name)s</code> - Alias for the channel name</li>
</ul>
</>}
position='top'
/>
</label>
<TextInput
leftSectionWidth='auto'
leftSection={<span style={{fontSize: '0.85em', paddingLeft: 4}}>{mediaDirectoryLabel}</span>}
value={state.videos_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('videos_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>Map Directory</label>
<TextInput
leftSectionWidth='auto'
leftSection={<span style={{fontSize: '0.85em', paddingLeft: 4}}>{mediaDirectoryLabel}</span>}
value={state.map_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('map_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>Zims Directory</label>
<TextInput
leftSectionWidth='auto'
leftSection={<span style={{fontSize: '0.85em', paddingLeft: 4}}>{mediaDirectoryLabel}</span>}
value={state.zims_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('zims_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<label>Playlists Directory</label>
<TextInput
leftSectionWidth='auto'
leftSection={<span style={{fontSize: '0.85em', paddingLeft: 4}}>{mediaDirectoryLabel}</span>}
value={state.playlists_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Toggle
label='Edit Directories'
disabled={disabled}
checked={editSpecialDirectories === true}
onChange={checked => setEditSpecialDirectories(checked)}
/>
</Grid.Col>
</Grid>
<Divider/>
<APIButton
id='settings_save_button'
color='violet'
role='save'
size='big'
onClick={() => localSaveSettings(state)}
obeyWROLMode={true}
disabled={disabled}
>Save</APIButton>
<Dimmer active={pendingSave}>
<Loader active={pendingSave} size='large'/>
</Dimmer>
{pendingSave && <Loading>Saving settings...</Loading>}
</Form>
</form>
</>
} else if (ready === false) {
body = <Loader active inline='centered'/>
body = <Loading/>
} else {
body = <ErrorMessage>Unable to fetch settings</ErrorMessage>
}
const configsSegment = <Segment>
const configsSegment = <Panel>
<RefreshHeader
header='Configs'
popupContents='Check if configs are valid'
@ -922,17 +901,17 @@ export function SettingsPage() {
<p>Install the WROLPi Root CA certificate on your devices to trust all HTTPS connections to this WROLPi.
You only need to do this once per device.</p>
<a href='/ca.crt' download='wrolpi-ca.crt'>
<Button primary><Icon name='download'/> Download Root CA Certificate</Button>
<Button role='primary' icon='download'>Download Root CA Certificate</Button>
</a>
</Segment>;
</Panel>;
return <Container fluid>
return <div>
<WROLModeMessage content='Settings are disabled because WROL Mode is enabled.'/>
<Segment>
<Panel>
<Header as='h2'>Settings</Header>
{body}
</Segment>
</Panel>
<ThemeSegment/>
@ -942,10 +921,10 @@ export function SettingsPage() {
<WROLModeSection/>
<Segment>
<Panel>
<Header as='h1'>Browser Settings</Header>
<APIButton onClick={clearAll}>Show All Hints</APIButton>
</Segment>
</Panel>
{/* Trace Level Modal - shown when user selects trace level in native mode */}
<Modal
@ -965,7 +944,7 @@ export function SettingsPage() {
? `Switching to debug mode in ${traceCountdown} seconds...`
: 'Switching API services...'}
</p>
<p style={{color: '#888', fontSize: '0.9em'}}>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
The API will restart. This page may temporarily lose connection.
</p>
</>
@ -979,7 +958,7 @@ export function SettingsPage() {
This will save your settings and restart the API service. The page may temporarily
lose connection during the switch.
</p>
<p style={{color: '#888', fontSize: '0.9em'}}>
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
You can switch back to production mode later from the Controller page.
</p>
</>
@ -987,21 +966,21 @@ export function SettingsPage() {
</Modal.Content>
<Modal.Actions>
<Button
color='grey'
role='cancel'
onClick={handleTraceModalCancel}
disabled={traceSwitching}
>
Cancel
</Button>
<Button
color='green'
role='save'
onClick={handleTraceModalConfirm}
disabled={traceSwitching}
icon='check'
>
<Icon name='check'/> Enable Debug Mode
Enable Debug Mode
</Button>
</Modal.Actions>
</Modal>
</Container>;
</div>;
}

View file

@ -1,4 +1,13 @@
import {Accordion, Header, Progress, Segment, Statistic, Table} from "../Theme";
import {
Accordion,
Grid,
Header,
Panel,
Progress,
Statistic,
StatisticGroup,
Table,
} from "../ui";
import React, {useContext} from "react";
import {
Duration,
@ -11,20 +20,8 @@ import {
useTitle
} from "../Common";
import {ProgressPlaceholder} from "../Placeholder";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Media, SettingsContext, StatusContext, ThemeContext} from "../../contexts/contexts";
import {
AccordionContent,
AccordionTitle,
SegmentGroup,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow
} from "semantic-ui-react";
import {Media, SettingsContext, StatusContext} from "../../contexts/contexts";
import _ from "lodash";
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
function DriveInfo({used, size, percent, mount}) {
let color;
@ -33,11 +30,11 @@ function DriveInfo({used, size, percent, mount}) {
} else if (percent >= 80) {
color = 'orange';
}
return <Progress progress
percent={percent}
label={`${mount} ${humanFileSize(used)} of ${humanFileSize(size)}`}
color={color}
key={mount}
return <Progress
percent={percent}
label={`${mount} ${humanFileSize(used)} of ${humanFileSize(size)}`}
color={color}
key={mount}
/>
}
@ -45,7 +42,7 @@ function DiskBandwidthProgress({bytes_ps, total, label, ...props}) {
// Calculate percent so colors can be shown.
let percent = (bytes_ps / total) * 100;
percent = percent || 0;
let color = null;
let color = undefined;
if (percent >= 90) {
color = 'red';
} else if (percent >= 80) {
@ -62,35 +59,30 @@ function DiskBandwidth({name, bytes_read_ps, bytes_write_ps, max_read_ps, max_wr
bytes_ps={bytes_read_ps}
total={max_read_ps}
label={`${name} read`}
size='tiny'
disabled={bytes_read_ps === 0}
/>;
const write = <DiskBandwidthProgress
bytes_ps={bytes_write_ps}
total={max_write_ps}
label={`${name} write`}
size='tiny'
disabled={bytes_write_ps === 0}
/>;
return <Grid columns={2}>
<Grid.Row>
<Grid.Column>{read}</Grid.Column>
<Grid.Column>{write}</Grid.Column>
</Grid.Row>
return <Grid>
<Grid.Col span={6}>{read}</Grid.Col>
<Grid.Col span={6}>{write}</Grid.Col>
</Grid>
}
function CPUTemperatureStatistic({temperature, high_temperature, critical_temperature, ...props}) {
if (!temperature) {
return <Statistic label='Temp C°' value='?'/>
return <Statistic label='Temp C°' value='?' {...props}/>
}
let color;
if ((critical_temperature && temperature >= critical_temperature) || (!critical_temperature && temperature >= 75)) {
props['color'] = 'red';
color = 'red';
} else if ((high_temperature && temperature >= high_temperature) || (!high_temperature && temperature >= 55)) {
props['color'] = 'orange';
color = 'orange';
}
return <Statistic label='Temp C°' value={temperature} {...props}/>
return <Statistic label='Temp C°' value={temperature} color={color} {...props}/>
}
function FanRPMStatistic({fan_rpm, ...props}) {
@ -103,14 +95,15 @@ function FanRPMStatistic({fan_rpm, ...props}) {
function IOWaitStatistic({percent_iowait, ...props}) {
if (percent_iowait === null || percent_iowait === undefined) {
return <Statistic label='IO Wait %' value='?'/>
return <Statistic label='IO Wait %' value='?' {...props}/>
}
let color;
if (percent_iowait >= 30) {
props['color'] = 'red';
color = 'red';
} else if (percent_iowait >= 10) {
props['color'] = 'orange';
color = 'orange';
}
return <Statistic label='IO Wait %' value={percent_iowait.toFixed(1)} {...props}/>
return <Statistic label='IO Wait %' value={percent_iowait.toFixed(1)} color={color} {...props}/>
}
function formatUptime(seconds) {
@ -140,13 +133,13 @@ export function BandwidthProgress({label = '', bytes, maxBytes, ...props}) {
label = `${label} (${humanBandwidth(bytes)})`;
const percent = (bytes / maxBytes) * 100;
let color;
if (percent > 70) {
props['color'] = 'yellow';
color = 'yellow';
} else if (percent > 90) {
props['color'] = 'red';
color = 'red';
}
const disabled = percent === 0;
return <Progress percent={percent} label={label} disabled={disabled} {...props}/>
return <Progress percent={percent} label={label} color={color} {...props}/>
}
export function BandwidthProgressGroup({bandwidth, ...props}) {
@ -157,7 +150,6 @@ export function BandwidthProgressGroup({bandwidth, ...props}) {
bytes={bandwidth['bytes_recv_ps']}
label={`${bandwidth['name']} In`}
maxBytes={maxBytes}
size='small'
{...props}
/>;
@ -165,19 +157,12 @@ export function BandwidthProgressGroup({bandwidth, ...props}) {
bytes={bandwidth['bytes_sent_ps']}
label={`${bandwidth['name']} Out`}
maxBytes={maxBytes}
size='small'
{...props}
/>;
return <Grid columns={2}>
<Grid.Row>
<Grid.Column>
{recv}
</Grid.Column>
<Grid.Column>
{sent}
</Grid.Column>
</Grid.Row>
return <Grid>
<Grid.Col span={6}>{recv}</Grid.Col>
<Grid.Col span={6}>{sent}</Grid.Col>
</Grid>
}
@ -189,10 +174,10 @@ export function BandwidthProgressCombined({bandwidth, ...props}) {
export function CPUUsageProgress({percent, label}) {
if (percent === null) {
return <Progress progress={0} color='grey' label='Average CPU Usage ERROR' disabled/>
return <Progress percent={0} color='grey' label='Average CPU Usage ERROR'/>
}
let color = 'black';
let color = undefined;
if (percent >= 90) {
color = 'red';
} else if (percent >= 70) {
@ -200,44 +185,37 @@ export function CPUUsageProgress({percent, label}) {
} else if (percent >= 50) {
color = 'orange';
}
return <Progress percent={percent} progress color={color} label={label}/>
return <Progress percent={percent} color={color} label={label}/>
}
export function MemoryUsageProgress({percent, label}) {
if (percent === null) {
return <Progress progress={0} color='grey' label='RAM Usage' disabled/>
return <Progress percent={0} color='grey' label='RAM Usage'/>
}
let color = 'black';
let size = 'small';
let color = undefined;
if (percent >= 90) {
color = 'red';
size = 'large';
} else if (percent >= 70) {
color = 'orange';
size = 'large';
}
return <Progress percent={percent} progress color={color} label={label} size={size}/>
return <Progress percent={percent} color={color} label={label}/>
}
function ProcessInfoRow({pid, command, percent_cpu, percent_mem}) {
const color = percent_cpu >= 80 ? 'orange' : null;
return <TableRow color={color}>
<TableCell className='column-ellipsis'>{command}</TableCell>
<TableCell textAlign='right'>{percent_cpu}</TableCell>
<TableCell textAlign='right'>{percent_mem}</TableCell>
<TableCell textAlign='right'>{pid}</TableCell>
</TableRow>
return <Table.Row failed={percent_cpu >= 80}>
<Table.Cell className='column-ellipsis'>{command}</Table.Cell>
<Table.Cell numeric>{percent_cpu}</Table.Cell>
<Table.Cell numeric>{percent_mem}</Table.Cell>
<Table.Cell numeric>{pid}</Table.Cell>
</Table.Row>
}
export function StatusPage() {
useTitle('Status');
const [activeIndex, setActiveIndex] = React.useState(null);
const {status} = useContext(StatusContext);
const {settings} = useContext(SettingsContext);
const {s} = useContext(ThemeContext);
let percent;
let cores;
@ -299,23 +277,19 @@ export function StatusPage() {
const cpuProgress = <CPUUsageProgress percent={percent} label={`CPU Usage (${cores} cores)`}/>;
const memoryUsageProgress = <MemoryUsageProgress percent={memoryPercent} label={`RAM Usage (${memorySize})`}/>;
const handleAccordionClick = (e, {index}) => {
setActiveIndex(activeIndex === index ? -1 : index);
}
const noProcessesRow = <Table.Row>
<Table.Cell colSpan={4}>No top processes</Table.Cell>
</Table.Row>
const noProcessesRow = <TableRow>
<TableCell colSpan={4}>No top processes</TableCell>
</TableRow>
return <SegmentGroup>
return <>
<Media at='mobile'>
<Segment>
<Panel>
{cpuProgress}
{memoryUsageProgress}
<Statistic.Group size='mini'>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores} style={{margin: '1em'}}/>
<LoadStatistic label='5 Min. Load' value={minute_5} cores={cores} style={{margin: '1m'}}/>
<LoadStatistic label='15 Min. Load' value={minute_15} cores={cores} style={{margin: '1em'}}/>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores}/>
<LoadStatistic label='5 Min. Load' value={minute_5} cores={cores}/>
<LoadStatistic label='15 Min. Load' value={minute_15} cores={cores}/>
<CPUTemperatureStatistic
id='cpu_temperature_statistic'
temperature={temperature}
@ -325,14 +299,14 @@ export function StatusPage() {
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
</Media>
<Media greaterThanOrEqual='tablet'>
<Segment>
<Panel>
{cpuProgress}
{memoryUsageProgress}
<Statistic.Group>
<StatisticGroup>
<LoadStatistic label='1 Min. Load' value={minute_1} cores={cores}/>
<LoadStatistic label='5 Min.' value={minute_5} cores={cores}/>
<LoadStatistic label='15 Min.' value={minute_15} cores={cores}/>
@ -341,16 +315,15 @@ export function StatusPage() {
temperature={temperature}
high_temperature={high_temperature}
critical_temperature={critical_temperature}
style={{marginRight: 0}}
/>
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>
</Segment>
</StatisticGroup>
</Panel>
</Media>
<Segment>
<Panel>
<InfoHeader
headerSize='h2'
headerContent='Drive Bandwidth'
@ -361,70 +334,68 @@ export function StatusPage() {
{!_.isEmpty(diskBandwidthStats) ?
Object.entries(diskBandwidthStats).map(([name, disk]) => <DiskBandwidth key={name} {...disk}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Network Bandwidth</SizedHeader>
{!_.isEmpty(nicBandwidthStats) ?
Object.entries(nicBandwidthStats).map(([name, stats]) => <BandwidthProgressGroup key={name}
bandwidth={stats}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Top Processes</SizedHeader>
<Table unstackable striped className='table-ellipsis'>
<TableHeader>
<TableRow>
<TableHeaderCell width={10}>Command</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>CPU %</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>Mem %</TableHeaderCell>
<TableHeaderCell width={2} textAlign='right'>PID</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Table className='table-ellipsis'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Command</Table.HeaderCell>
<Table.HeaderCell>CPU %</Table.HeaderCell>
<Table.HeaderCell>Mem %</Table.HeaderCell>
<Table.HeaderCell>PID</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{processesStats && processesStats.length === 0 ?
noProcessesRow :
processesStats && processesStats.length > 0 ?
processesStats.map(i => <ProcessInfoRow key={i.pid} {...i}/>)
: <TableRow><TableCell><ProgressPlaceholder/></TableCell></TableRow>
: <Table.Row><Table.Cell colSpan={4}><ProgressPlaceholder/></Table.Cell></Table.Row>
}
</TableBody>
</Table.Body>
</Table>
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader>Drive Usage</SizedHeader>
{drivesStats && drivesStats.length > 0 ? drivesStats.map((drive) => <DriveInfo
key={drive['mount']} {...drive}/>)
: <ProgressPlaceholder/>}
</Segment>
</Panel>
<Segment>
<Panel>
<SizedHeader sizeMobile={'h2'} sizeTablet={'h3'}>Developer</SizedHeader>
<Accordion>
<AccordionTitle onClick={handleAccordionClick} index={0}>
<Icon name='dropdown'/>
Status Details
</AccordionTitle>
<AccordionContent active={activeIndex === 0}>
<pre {...s}>
{JSON.stringify(status, undefined, 1)}
</pre>
</AccordionContent>
<Accordion.Item value='status'>
<Accordion.Control>Status Details</Accordion.Control>
<Accordion.Panel>
<pre style={{background: 'var(--panel)', color: 'var(--text)', padding: '0.5em'}}>
{JSON.stringify(status, undefined, 1)}
</pre>
</Accordion.Panel>
</Accordion.Item>
<AccordionTitle onClick={handleAccordionClick} index={1}>
<Icon name='dropdown'/>
Settings Details
</AccordionTitle>
<AccordionContent active={activeIndex === 1}>
<pre {...s}>
{JSON.stringify(settings, undefined, 1)}
</pre>
</AccordionContent>
<Accordion.Item value='settings'>
<Accordion.Control>Settings Details</Accordion.Control>
<Accordion.Panel>
<pre style={{background: 'var(--panel)', color: 'var(--text)', padding: '0.5em'}}>
{JSON.stringify(settings, undefined, 1)}
</pre>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Segment>
</SegmentGroup>
</Panel>
</>
}

View file

@ -1,10 +1,9 @@
import React from "react";
import {Form, Header, Segment, Table} from "../Theme";
import {Button, GridColumn, GridRow, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Button, ButtonGroup, Grid, Group, Header, IconButton, NumberInput, Panel, Table} from "../ui";
import {ColoredInput} from "../Apps";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
import {Media} from "../../contexts/contexts";
import {IconSettings, IconSettings2} from "@tabler/icons-react";
// Pure calculation functions for the Drive Ratio calculator (pulleys, gears, sprockets).
//
@ -206,13 +205,15 @@ export function gearPoints(cx, cy, rOuter, rInner, teeth, tipFrac = 0.5) {
// Per-mode wording. All three drive types share the same math; only the size
// label (and the diagram) differ.
const MODES = {
pulley: {button: 'Pulley', icon: 'circle outline', sizeLabel: 'Diameter', toothed: false},
gear: {button: 'Gear', icon: 'cogs', sizeLabel: 'Teeth', toothed: true},
sprocket: {button: 'Sprocket', icon: 'cog', sizeLabel: 'Teeth', toothed: true},
pulley: {button: 'Pulley', icon: 'circle', sizeLabel: 'Diameter', toothed: false},
gear: {button: 'Gear', icon: IconSettings, sizeLabel: 'Teeth', toothed: true},
sprocket: {button: 'Sprocket', icon: IconSettings2, sizeLabel: 'Teeth', toothed: true},
};
// Distinct, colorblind-friendly wheel colors (Paul Tol's bright palette):
// blue, red, green, yellow, cyan, purple. Grey is reserved as a neutral.
// These are diagram/data-visualization colors, matched 1:1 to the drawn wheels
// below; they are not themed UI chrome, so they stay literal hex.
const ELEMENT_COLORS = ['#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377'];
const MIN_ELEMENTS = 2;
@ -393,6 +394,20 @@ function fmtSpeed(speed) {
return speed ? `${roundDigits(speed.value, 3)} ${speed.unit}` : '—';
}
// One wheel's size/RPM input, its label tinted to match its diagram color. The
// color is a literal hex (matched to the diagram, not a theme token), so this is
// a small local composition rather than the token-driven `Label`.
function WheelInput({hex, label, value, onChange, name, min, step}) {
return <Group gap={0} wrap='nowrap' align='stretch' style={{marginBottom: '0.25em'}}>
<span style={{
display: 'flex', alignItems: 'center', padding: '0 10px', fontSize: 13, fontWeight: 500,
whiteSpace: 'nowrap', background: hex, color: textColorFor(hex), border: `1px solid ${hex}`,
}}>{label}</span>
<NumberInput name={name} value={value} onChange={onChange} min={min} step={step}
onFocus={e => e.target.select()} hideControls style={{flex: 1}}/>
</Group>;
}
export function DriveCalculator() {
const [mode, setMode] = React.useState('pulley');
const [metric, setMetric] = useLocalStorage('drive_calculator_metric', false);
@ -433,20 +448,10 @@ export function DriveCalculator() {
const ratio = driveRatio(firstSize, lastSize);
const outTorque = torqueIn !== '' ? torqueOut(Number(torqueIn), firstSize, lastSize) : null;
const inputProps = {
fluid: true,
type: 'number',
onSelect: e => e.target.select(),
autoComplete: 'off',
};
// Each wheel's inputs use its diagram color so the form and the picture line up.
const coloredField = (hex, labelText, value, onChange, extraProps = {}, name) =>
<Input {...inputProps} {...extraProps} name={name} labelPosition='left' value={value} onChange={onChange}
label={<Label style={{backgroundColor: hex, color: textColorFor(hex), borderColor: hex}}>
{labelText}</Label>}/>;
// Auxiliary (non-solver) numeric input.
const auxInput = (label, value, setter, name) =>
<ColoredInput {...inputProps} name={name} label={label} value={value}
<ColoredInput fluid type='number' onSelect={e => e.target.select()} autoComplete='off'
name={name} label={label} value={value}
onChange={e => setter(e.target.value)}/>;
// Pitch diameter of a wheel given the mode's extra parameter. Pulley size IS the
@ -468,43 +473,47 @@ export function DriveCalculator() {
? surfaceSpeedDisplay(pitchDiameter(Number(speedEl.size)), Number(speedEl.rpm), metric)
: null;
const modeButtons = <Button.Group style={{marginBottom: '1em', flexWrap: 'wrap'}}>
const modeButtons = <ButtonGroup style={{marginBottom: '1em', flexWrap: 'wrap'}}>
{Object.entries(MODES).map(([key, m]) =>
<Button key={key} type='button' icon={m.icon} content={m.button}
primary={mode === key} onClick={() => setMode(key)}/>)}
</Button.Group>;
<Button key={key} type='button' icon={m.icon} role={mode === key ? 'primary' : 'cancel'}
onClick={() => setMode(key)}>{m.button}</Button>)}
</ButtonGroup>;
// One row of inputs per wheel: size, RPM, and a remove button (above the minimum).
const elementRows = elements.map((el, i) => {
const hex = ELEMENT_COLORS[i % ELEMENT_COLORS.length];
const onField = (field) => (e) => {
dispatch({type: 'field', index: i, field, value: e.target.value});
const onField = (field) => (value) => {
dispatch({type: 'field', index: i, field, value});
scheduleSolve();
};
// Teeth are whole numbers; pulley diameters stay continuous.
const onSize = toothed
? (e) => {
dispatch({type: 'field', index: i, field: 'size',
value: e.target.value === '' ? '' : String(Math.round(Math.abs(Number(e.target.value))))});
? (value) => {
dispatch({
type: 'field', index: i, field: 'size',
value: value === '' ? '' : String(Math.round(Math.abs(Number(value)))),
});
scheduleSolve();
}
: onField('size');
return {
hex,
sizeField: coloredField(hex, `#${i + 1} ${sizeLabel}${sizeUnit}`, el.size, onSize,
toothed ? {step: 1, min: 1} : {}, `size-${i}`),
rpmField: coloredField(hex, `#${i + 1} RPM`, el.rpm, onField('rpm'), {}, `rpm-${i}`),
sizeField: <WheelInput hex={hex} label={`#${i + 1} ${sizeLabel}${sizeUnit}`} value={el.size}
onChange={onSize} name={`size-${i}`} min={toothed ? 1 : undefined}
step={toothed ? 1 : undefined}/>,
rpmField: <WheelInput hex={hex} label={`#${i + 1} RPM`} value={el.rpm}
onChange={onField('rpm')} name={`rpm-${i}`}/>,
removeBtn: elements.length > MIN_ELEMENTS
? <Button type='button' basic color='red' icon='close' size='small'
aria-label={`Remove #${i + 1}`} onClick={() => dispatch({type: 'remove', index: i})}/>
? <IconButton type='button' role='danger' icon='close' size='sm'
label={`Remove #${i + 1}`} onClick={() => dispatch({type: 'remove', index: i})}/>
: null,
};
});
const addButton = <Button type='button' icon='plus' primary content={`Add ${button}`}
const addButton = <Button type='button' icon='plus' role='primary'
disabled={elements.length >= MAX_ELEMENTS}
onClick={() => dispatch({type: 'add'})}
style={{marginTop: '0.5em'}}/>;
style={{marginTop: '0.5em'}}>Add {button}</Button>;
// Small colored swatch matching a wheel, used to label its detail rows.
const swatch = (hex) => <span style={{
@ -514,10 +523,10 @@ export function DriveCalculator() {
// Per-wheel pitch-diameter rows (gear/sprocket only).
const pitchDiameterRows = toothed ? elements.map((el, i) =>
<TableRow key={i}>
<TableCell>{swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter</TableCell>
<TableCell>{fmt(pitchDiameter(Number(el.size)), lengthUnit)}</TableCell>
</TableRow>) : null;
<Table.Row key={i}>
<Table.Cell>{swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter</Table.Cell>
<Table.Cell>{fmt(pitchDiameter(Number(el.size)), lengthUnit)}</Table.Cell>
</Table.Row>) : null;
// Pairwise belt/chain length and center distance only make sense for a two-wheel
// drive; with 3+ wheels they depend on the physical layout, so they are omitted.
@ -526,38 +535,38 @@ export function DriveCalculator() {
const belt = twoElements ? beltLength(firstSize, lastSize, Number(centerDistance)) : null;
detailRows = <>
{twoElements && <>
<TableRow>
<TableCell width={6}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
</TableRow>
<TableRow>
<TableCell>Belt Length</TableCell>
<TableCell>{fmt(belt, lengthUnit)}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></Table.Cell>
<Table.Cell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Belt Length</Table.Cell>
<Table.Cell>{fmt(belt, lengthUnit)}</Table.Cell>
</Table.Row>
</>}
<TableRow>
<TableCell width={6}>Belt Speed</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Belt Speed</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
} else if (mode === 'gear') {
const paramLabel = metric ? 'Module (mm)' : 'Diametral Pitch (1/in)';
const pd1 = pitchDiameter(firstSize), pd2 = pitchDiameter(lastSize);
const cd = twoElements && pd1 > 0 && pd2 > 0 ? centerDistanceFromPitchDiameters(pd1, pd2) : null;
detailRows = <>
<TableRow>
<TableCell width={6}>{paramLabel}</TableCell>
<TableCell>{auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}</TableCell>
</TableRow>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>{paramLabel}</Table.Cell>
<Table.Cell>{auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}</Table.Cell>
</Table.Row>
{pitchDiameterRows}
{twoElements && <TableRow>
<TableCell>Center Distance</TableCell>
<TableCell>{fmt(cd, lengthUnit)}</TableCell>
</TableRow>}
<TableRow>
<TableCell>Pitch-Line Velocity</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance</Table.Cell>
<Table.Cell>{fmt(cd, lengthUnit)}</Table.Cell>
</Table.Row>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Pitch-Line Velocity</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
} else { // sprocket
const pitch = Number(chainPitch);
@ -565,27 +574,27 @@ export function DriveCalculator() {
? chainLengthPitchesEven(firstSize, lastSize, Number(centerDistance), pitch)
: null;
detailRows = <>
<TableRow>
<TableCell width={6}>Chain Pitch</TableCell>
<TableCell>{auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}</TableCell>
</TableRow>
{twoElements && <TableRow>
<TableCell>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
</TableRow>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Pitch</Table.Cell>
<Table.Cell>{auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}</Table.Cell>
</Table.Row>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></Table.Cell>
<Table.Cell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</Table.Cell>
</Table.Row>}
{pitchDiameterRows}
{twoElements && <TableRow>
<TableCell>Chain Length</TableCell>
<TableCell>{links === null ? '—' : `${links} pitches`}</TableCell>
</TableRow>}
<TableRow>
<TableCell>Chain Speed</TableCell>
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
</TableRow>
{twoElements && <Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Length</Table.Cell>
<Table.Cell>{links === null ? '—' : `${links} pitches`}</Table.Cell>
</Table.Row>}
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Chain Speed</Table.Cell>
<Table.Cell>{fmtSpeed(trainSpeed)}</Table.Cell>
</Table.Row>
</>;
}
return <Form>
return <div>
<Header as='h1'>Drive Ratio</Header>
<div style={{marginBottom: '1em'}}>{modeButtons}</div>
@ -605,53 +614,52 @@ export function DriveCalculator() {
</Media>
<Media greaterThanOrEqual='tablet'>
<Grid verticalAlign='middle'>
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
<GridRow key={i}>
<GridColumn width={7}>{sizeField}</GridColumn>
<GridColumn width={7}>{rpmField}</GridColumn>
<GridColumn width={2}>{removeBtn}</GridColumn>
</GridRow>)}
</Grid>
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
<Grid key={i} align='center'>
<Grid.Col span={5}>{sizeField}</Grid.Col>
<Grid.Col span={5}>{rpmField}</Grid.Col>
<Grid.Col span={2}>{removeBtn}</Grid.Col>
</Grid>)}
</Media>
<div>{addButton}</div>
<Segment style={{textAlign: 'center', overflowX: 'auto'}}>
<Panel style={{textAlign: 'center', overflowX: 'auto'}}>
<DriveDiagram mode={mode} sizes={elements.map(el => el.size)}/>
</Segment>
</Panel>
<Table definition unstackable>
<TableBody>
<TableRow>
<TableCell width={6}>Drive Ratio{' '}
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Drive Ratio{' '}
<span style={{fontWeight: 'normal', opacity: 0.7}}>(#1 → #{elements.length})</span>
</TableCell>
<TableCell>{formatRatio(ratio)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Torque Multiplier</TableCell>
<TableCell>{ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}</TableCell>
</TableRow>
<TableRow>
<TableCell>Output Torque <InfoPopup content={TORQUE_INFO}/></TableCell>
<TableCell>
<ColoredInput {...inputProps} name="torqueIn" label={`Input Torque (${torqueUnit})`}
</Table.Cell>
<Table.Cell>{formatRatio(ratio)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Torque Multiplier</Table.Cell>
<Table.Cell>{ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Output Torque <InfoPopup content={TORQUE_INFO}/></Table.Cell>
<Table.Cell>
<ColoredInput fluid type='number' onSelect={e => e.target.select()} autoComplete='off'
name="torqueIn" label={`Input Torque (${torqueUnit})`}
value={torqueIn}
onChange={e => setTorqueIn(e.target.value)}/>
{outTorque !== null && <span style={{marginLeft: '1em'}}>
= <b>{roundDigits(outTorque, 3)} {torqueUnit}</b>
</span>}
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<Header as='h3' style={{marginTop: '1em'}}>{button} Details</Header>
<Table definition unstackable>
<TableBody>{detailRows}</TableBody>
<Table>
<Table.Body>{detailRows}</Table.Body>
</Table>
</Form>;
</div>;
}
// Exported for testing

View file

@ -1,23 +1,8 @@
import {InfoPopup, roundDigits, Toggle, useLocalStorage, useLocalStorageInt} from "../Common";
import React, {useState} from "react";
import {Form, Header, Segment, Table} from "../Theme";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {
Container,
Dropdown,
GridColumn,
GridRow,
Icon,
Input,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "semantic-ui-react";
import {Grid, Header, Message, Panel, Select, Stack, Table} from "../ui";
import {ColoredInput} from "../Apps";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {Media, ThemeContext} from "../../contexts/contexts";
import {Media} from "../../contexts/contexts";
const initialState = {
volts: '',
@ -119,7 +104,6 @@ export function OhmsLawCalculator({setVolts}) {
const inputProps = {
fluid: true,
type: 'number',
labelPosition: 'right',
onSelect: e => e.target.select(), // Select the contents of the Input when a user selects it.
autoComplete: 'off',
};
@ -133,43 +117,39 @@ export function OhmsLawCalculator({setVolts}) {
return <div>
<Header as='h1'>Ohm's Law</Header>
<Grid columns={2}>
<GridRow>
<GridColumn>
<ColoredInput {...inputProps}
value={state.volts}
onChange={e => dispatch({type: 'volts', value: e.target.value})}
label='Volts'
color={calculateColor('volts')}
/>
</GridColumn>
<GridColumn>
<ColoredInput {...inputProps}
value={state.amps}
onChange={e => dispatch({type: 'amps', value: e.target.value})}
label='Amps'
color={calculateColor('amps')}
/>
</GridColumn>
</GridRow>
<GridRow>
<GridColumn>
<ColoredInput {...inputProps}
value={state.ohms}
onChange={e => dispatch({type: 'ohms', value: e.target.value})}
label='Ohms'
color={calculateColor('ohms')}
/>
</GridColumn>
<GridColumn>
<ColoredInput {...inputProps}
value={state.watts}
onChange={e => dispatch({type: 'watts', value: e.target.value})}
label='Watts'
color={calculateColor('watts')}
/>
</GridColumn>
</GridRow>
<Grid>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.volts}
onChange={e => dispatch({type: 'volts', value: e.target.value})}
label='Volts'
color={calculateColor('volts')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.amps}
onChange={e => dispatch({type: 'amps', value: e.target.value})}
label='Amps'
color={calculateColor('amps')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.ohms}
onChange={e => dispatch({type: 'ohms', value: e.target.value})}
label='Ohms'
color={calculateColor('ohms')}
/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 6}}>
<ColoredInput {...inputProps}
value={state.watts}
onChange={e => dispatch({type: 'watts', value: e.target.value})}
label='Watts'
color={calculateColor('watts')}
/>
</Grid.Col>
</Grid>
</div>
}
@ -301,10 +281,10 @@ export const resistancesPerKm = {
// Dropdown options for wire types
const wireTypeOptions = [
{key: 'solid', text: 'Solid Copper', value: 'solid'},
{key: 'stranded', text: 'Stranded Copper', value: 'stranded'},
{key: 'solid_aluminum', text: 'Solid Aluminum', value: 'solid_aluminum'},
{key: 'stranded_aluminum', text: 'Stranded Aluminum', value: 'stranded_aluminum'},
{value: 'solid', label: 'Solid Copper'},
{value: 'stranded', label: 'Stranded Copper'},
{value: 'solid_aluminum', label: 'Solid Aluminum'},
{value: 'stranded_aluminum', label: 'Stranded Aluminum'},
];
// Helper function to calculate power loss
@ -325,18 +305,15 @@ export const calcPowerLossPercent = (isSAE, volts, amps, resistancePerKiloLength
const PowerLossCalculator = ({volts, setVolts, wireType, setWireType, isSAE, setIsSAE, length, setLength}) => {
const [ampsRange] = useState([1, 5, 10, 20, 40, 100]);
const {inverted} = React.useContext(ThemeContext);
const warningBackgroundColor = inverted === 'inverted' ? '#332020' : '#ffbebe';
const resistances = isSAE ? resistancesPerKFeet[wireType]
: resistancesPerKm[wireType];
const voltsInput = <Input fluid
const voltsInput = <ColoredInput fluid
label='Volts'
labelPosition='right'
type='number'
value={volts}
onChange={(e, {value}) => setVolts(parseInt(value))}
onChange={e => setVolts(parseInt(e.target.value))}
autoComplete="off"
/>;
@ -346,91 +323,79 @@ const PowerLossCalculator = ({volts, setVolts, wireType, setWireType, isSAE, set
onChange={() => setIsSAE(!isSAE)}
/>;
const lengthInput = <Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={12} tablet={12}>
<Input fluid
label={isSAE ? 'feet' : 'meters'}
labelPosition='right'
type='number'
value={length}
onChange={(e, {value}) => setLength(parseInt(value))}
autoComplete="off"
/>
</Grid.Column>
<Grid.Column mobile={4} tablet={2}>
<InfoPopup
content='Length is one way. This length will be doubled to account for return wire.'/>
</Grid.Column>
</Grid.Row>
const lengthInput = <Grid>
<Grid.Col span={10}>
<ColoredInput fluid
label={isSAE ? 'feet' : 'meters'}
labelPosition='right'
type='number'
value={length}
onChange={e => setLength(parseInt(e.target.value))}
autoComplete="off"
/>
</Grid.Col>
<Grid.Col span={2}>
<InfoPopup
content='Length is one way. This length will be doubled to account for return wire.'/>
</Grid.Col>
</Grid>;
const wireTypeDropdown = <Dropdown
const wireTypeDropdown = <Select
placeholder='Select Wire Type'
fluid
selection
options={wireTypeOptions}
data={wireTypeOptions}
value={wireType}
onChange={(e, {value}) => setWireType(value)}
onChange={value => setWireType(value)}
/>;
return <Form>
return <div>
<Header as='h1'>Power Loss</Header>
<Media at='mobile'>
<Grid columns={2} divided stackable>
<Grid.Row>
<Grid.Column>{voltsInput}</Grid.Column>
<Grid.Column>{lengthInput}</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>{wireTypeDropdown}</Grid.Column>
<Grid.Column>{saeToggle}</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={6}>{voltsInput}</Grid.Col>
<Grid.Col span={6}>{lengthInput}</Grid.Col>
<Grid.Col span={6}>{wireTypeDropdown}</Grid.Col>
<Grid.Col span={6}>{saeToggle}</Grid.Col>
</Grid>
</Media>
<Media greaterThan='mobile'>
<Grid columns={2} divided>
<Grid.Row>
<Grid.Column>{voltsInput}</Grid.Column>
<Grid.Column>{saeToggle}</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>{lengthInput}</Grid.Column>
<Grid.Column>{wireTypeDropdown}</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={6}>{voltsInput}</Grid.Col>
<Grid.Col span={6}>{saeToggle}</Grid.Col>
<Grid.Col span={6}>{lengthInput}</Grid.Col>
<Grid.Col span={6}>{wireTypeDropdown}</Grid.Col>
</Grid>
</Media>
<Table unstackable compact celled definition>
<TableHeader>
<TableRow>
<TableHeaderCell/>
{ampsRange.map(amp => <TableHeaderCell key={amp}>{amp}A</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{ampsRange.map(amp => <Table.HeaderCell key={amp}>{amp}A</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{Object.entries(resistances).map(([size, resistence]) => (
<TableRow key={size}>
<TableCell>{size}</TableCell>
<Table.Row key={size}>
<Table.Cell style={{fontWeight: 600}}>{size}</Table.Cell>
{ampsRange.map(amp => {
const lossPercentage = calcPowerLossPercent(isSAE, volts, amp, resistence, length);
const style = lossPercentage > 2 ? {backgroundColor: warningBackgroundColor} : null;
return <TableCell key={`${size}-${amp}`} style={style}>
// The warning tint blends the amber token into the panel background, so
// it stays a token-driven color in every theme rather than a fixed hex.
const style = lossPercentage > 2
? {backgroundColor: 'color-mix(in srgb, var(--amber) 30%, var(--panel))'}
: null;
return <Table.Cell key={`${size}-${amp}`} style={style}>
{lossPercentage >= 100 ? '-' : roundDigits(lossPercentage, 1) + '%'}
</TableCell>
</Table.Cell>
})}
</TableRow>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
<Message icon color='yellow'>
<Icon name='exclamation'/>
<Message.Content>
<Message.Header>Warning</Message.Header>
These numbers are only an estimate and are only applicable for bare wires.
</Message.Content>
<Message kind='warning' icon='warning' title='Warning'>
These numbers are only an estimate and are only applicable for bare wires.
</Message>
</Form>
</div>
};
// Resistance changes with temperature; the resistance tables are referenced at 20°C.
@ -479,7 +444,7 @@ const FourWireKelvinCalculator = ({wireType, isSAE, length}) => {
setTemperatureC(isSAE ? (temperature - 32) * 5 / 9 : temperature);
};
return <Form>
return <div>
<Header as='h1'>4-Wire Kelvin</Header>
<p>
Expected millivolt drop across {length} {isSAE ? 'feet' : 'meters'} of wire when driving a known
@ -488,48 +453,46 @@ const FourWireKelvinCalculator = ({wireType, isSAE, length}) => {
content='Uses the length and wire type from the Power Loss calculator above. The length is NOT doubled because the sense leads measure across a single conductor.'/>
</p>
<Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={12} tablet={12}>
<Input fluid
label={isSAE ? '°F' : '°C'}
labelPosition='right'
type='number'
value={displayTemperature}
onChange={(e, {value}) => handleTemperatureChange(value)}
autoComplete="off"
/>
</Grid.Column>
<Grid.Column mobile={4} tablet={2}>
<InfoPopup
content='Wire temperature. Resistances are referenced at 20°C (68°F) and corrected using the temperature coefficient of the wire material (copper 0.393% per °C, aluminum 0.403% per °C).'/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={10}>
<ColoredInput fluid
label={isSAE ? '°F' : '°C'}
labelPosition='right'
type='number'
value={displayTemperature}
onChange={e => handleTemperatureChange(e.target.value)}
autoComplete="off"
/>
</Grid.Col>
<Grid.Col span={2}>
<InfoPopup
content='Wire temperature. Resistances are referenced at 20°C (68°F) and corrected using the temperature coefficient of the wire material (copper 0.393% per °C, aluminum 0.403% per °C).'/>
</Grid.Col>
</Grid>
<Table unstackable compact celled definition>
<TableHeader>
<TableRow>
<TableHeaderCell/>
{ampsRange.map(amp => <TableHeaderCell key={amp}>{amp}A</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{ampsRange.map(amp => <Table.HeaderCell key={amp}>{amp}A</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{Object.entries(resistances).map(([size, resistance]) => (
<TableRow key={size}>
<TableCell>{size}</TableCell>
<Table.Row key={size}>
<Table.Cell style={{fontWeight: 600}}>{size}</Table.Cell>
{ampsRange.map(amp => {
const milliVolts = calcKelvinMilliVoltDrop(
amp, resistance, length, temperatureC, tempCoefficientForWireType(wireType));
return <TableCell key={`${size}-${amp}`}>
return <Table.Cell key={`${size}-${amp}`}>
{roundDigits(milliVolts, 1)} mV
</TableCell>
</Table.Cell>
})}
</TableRow>
</Table.Row>
))}
</TableBody>
</Table.Body>
</Table>
</Form>
</div>
};
export const ElectricalCalculators = () => {
@ -537,12 +500,12 @@ export const ElectricalCalculators = () => {
const [wireType, setWireType] = useLocalStorage('calculators.power_loss.wire_type', 'solid');
const [isSAE, setIsSAE] = useLocalStorage('calculators.power_loss.is_sae', true);
const [length, setLength] = useLocalStorageInt('calculators.power_loss.length', 100);
return <Container>
<Segment><OhmsLawCalculator setVolts={setVolts}/></Segment>
<Segment><PowerLossCalculator volts={volts} setVolts={setVolts}
return <Stack>
<Panel><OhmsLawCalculator setVolts={setVolts}/></Panel>
<Panel><PowerLossCalculator volts={volts} setVolts={setVolts}
wireType={wireType} setWireType={setWireType}
isSAE={isSAE} setIsSAE={setIsSAE}
length={length} setLength={setLength}/></Segment>
<Segment><FourWireKelvinCalculator wireType={wireType} isSAE={isSAE} length={length}/></Segment>
</Container>
length={length} setLength={setLength}/></Panel>
<Panel><FourWireKelvinCalculator wireType={wireType} isSAE={isSAE} length={length}/></Panel>
</Stack>
}

View file

@ -1,9 +1,7 @@
import React from "react";
import {GridColumn, Input, Label, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form, Header, Segment, Table} from "../Theme";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
import {ThemeContext} from "../../contexts/contexts";
import {Grid, Header, Panel, Table, TextInput, Toggle} from "../ui";
import {ColoredInput} from "../Apps";
import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
// Generic food-storage planner: given a household, estimate how much of each food category to store for a chosen
// duration. Independent of any inventory (like the Water calculator). Defaults are a common one-year-per-adult
@ -97,19 +95,14 @@ const SHARES_INFO = 'Each person\'s share of one adult man\'s ration. Defaults f
+ 'Americans 2020-2025 moderate-activity calorie needs (man ~2,600, woman ~2,000, child ~1,800 kcal/day) — '
+ 'food storage scales with calories. Children vary widely by age, so adjust as needed.';
// Category colors are theme tokens (not hex) so the tags stay legible — and become outlines instead of fills — in
// night mode.
const PEOPLE_META = [
{key: 'men', label: 'Men', color: '#4477aa'},
{key: 'women', label: 'Women', color: '#aa3377'},
{key: 'children', label: 'Children', color: '#228833'},
{key: 'men', label: 'Men', color: 'blue'},
{key: 'women', label: 'Women', color: 'pink'},
{key: 'children', label: 'Children', color: 'green'},
];
function textColorFor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
}
function fmt(value, unit) {
return Number.isFinite(value) && value > 0 ? `${roundDigits(value, 1)} ${unit}` : '—';
}
@ -120,7 +113,6 @@ export function FoodStorageCalculator() {
const [monthsIndex, setMonthsIndex] = useLocalStorage('food_storage_months_index', MONTH_STOPS.indexOf(12));
const [amounts, setAmounts] = useLocalStorage('food_storage_amounts', DEFAULT_AMOUNTS);
const [metric, setMetric] = useLocalStorage('food_storage_metric', false);
const {t} = React.useContext(ThemeContext);
const unit = metric ? 'kg' : 'lb';
// Convert the editable amounts when switching units so the user keeps their numbers.
@ -135,18 +127,13 @@ export function FoodStorageCalculator() {
const inputProps = {fluid: true, type: 'number', min: 0, onSelect: e => e.target.select(), autoComplete: 'off'};
const labelStyle = (color) =>
({backgroundColor: color, color: textColorFor(color), borderColor: color});
const countInput = ({key, label, color}) =>
<Input {...inputProps} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
<ColoredInput {...inputProps} step={1} name={`count-${key}`} label={label} color={color}
value={counts[key]} onChange={e => setCounts({...counts, [key]: e.target.value})}/>;
const shareInput = ({key, label, color}) =>
<Input {...inputProps} step={1} name={`share-${key}`} labelPosition='left' value={shares[key]}
onChange={e => setShares({...shares, [key]: e.target.value})}
label={<Label style={labelStyle(color)}>{label}</Label>}/>;
<ColoredInput {...inputProps} step={1} name={`share-${key}`} label={label} color={color}
value={shares[key]} onChange={e => setShares({...shares, [key]: e.target.value})}/>;
const rows = FOOD_CATEGORIES.map(c => {
const perYear = amounts[c.key] ?? '';
@ -155,26 +142,26 @@ export function FoodStorageCalculator() {
});
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
return <Form>
return <div>
<Header as='h1'>Food Storage</Header>
<p {...t}>Estimate how much food to store for your household over time.</p>
<p>Estimate how much food to store for your household over time.</p>
<div style={{marginBottom: '1em'}}>
<Toggle label={metric ? 'Metric (kg)' : 'Imperial (lb)'} checked={metric} onChange={toggleMetric}/>
</div>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
<Grid>
{PEOPLE_META.map(meta => <Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{countInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h4' style={{marginTop: '1em', marginBottom: '0.5em'}}>
Ration share (% of an adult man) <InfoPopup content={SHARES_INFO}/>
</Header>
<Grid stackable columns={3}>
{PEOPLE_META.map(meta => <GridColumn key={meta.key}>{shareInput(meta)}</GridColumn>)}
<Grid>
{PEOPLE_META.map(meta => <Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{shareInput(meta)}</Grid.Col>)}
</Grid>
<p {...t} style={{...t.style, marginTop: '0.5em', opacity: 0.8}}>
<p style={{marginTop: '0.5em', opacity: 0.8}}>
≈ {roundDigits(equivalents, 2)} adult-equivalent{equivalents === 1 ? '' : 's'}
</p>
@ -183,40 +170,40 @@ export function FoodStorageCalculator() {
<input type='range' min={0} max={MONTH_STOPS.length - 1} step={1} value={monthStop}
aria-label='Duration' onChange={e => setMonthsIndex(Number(e.target.value))}
style={{width: '100%'}}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>{formatMonths(months)}</Header>
<Header as='h2' style={{marginTop: '0.25em', textAlign: 'center'}}>{formatMonths(months)}</Header>
</div>
<Header as='h3' style={{marginTop: '1em'}}>
Amounts ({unit} per person per year) <InfoPopup content={CATEGORY_INFO}/>
</Header>
<Segment>
<Table unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell width={7}>Category</TableHeaderCell>
<TableHeaderCell width={5}>{unit === 'kg' ? 'Kg' : 'Lbs'} / person / year</TableHeaderCell>
<TableHeaderCell width={4}>Total needed</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(r => <TableRow key={r.key}>
<TableCell>{r.label}</TableCell>
<TableCell>
<Input fluid type='number' min={0} value={r.perYear}
aria-label={`${r.label} per person per year`}
onChange={e => setAmounts({...amounts, [r.key]: e.target.value})}/>
</TableCell>
<TableCell>{fmt(r.total, unit)}</TableCell>
</TableRow>)}
</TableBody>
<TableFooter>
<TableRow>
<TableHeaderCell>Total</TableHeaderCell>
<TableHeaderCell/>
<TableHeaderCell>{fmt(grandTotal, unit)}</TableHeaderCell>
</TableRow>
</TableFooter>
<Panel>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Category</Table.HeaderCell>
<Table.HeaderCell>{unit === 'kg' ? 'Kg' : 'Lbs'} / person / year</Table.HeaderCell>
<Table.HeaderCell>Total needed</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map(r => <Table.Row key={r.key}>
<Table.Cell>{r.label}</Table.Cell>
<Table.Cell>
<TextInput type='number' min={0} value={r.perYear} style={{width: '100%'}}
aria-label={`${r.label} per person per year`}
onChange={e => setAmounts({...amounts, [r.key]: e.target.value})}/>
</Table.Cell>
<Table.Cell>{fmt(r.total, unit)}</Table.Cell>
</Table.Row>)}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell>Total</Table.HeaderCell>
<Table.HeaderCell/>
<Table.HeaderCell>{fmt(grandTotal, unit)}</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</Segment>
</Form>;
</Panel>
</div>;
}

View file

@ -1,9 +1,7 @@
import React from "react";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Header, Table} from "../Theme";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {roundDigits} from "../Common";
import {Grid, Header, Table} from "../ui";
import {ColoredInput} from "../Apps";
import {roundDigits} from "../Common";
// ---------------------------------------------------------------------------
// Pure antenna math
@ -212,9 +210,10 @@ export function DipoleAntennaCalculator() {
setMhzInputValue(storageMhzValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMhzChange = (e, {value}) => {
const handleMhzChange = (value) => {
setMhzInputValue(value);
const parsed = parseFloat(value);
// Require a finite positive frequency so overflow (e.g. 1e999 → Infinity) does not stick.
@ -222,7 +221,7 @@ export function DipoleAntennaCalculator() {
localStorage.setItem(mhzStorageKey, value);
}
const handleWaveChange = (e, {value}) => {
const handleWaveChange = (value) => {
setWaveInputValue(value);
const parsed = parseFloat(value);
if (value && Number.isFinite(parsed) && parsed > 0) {
@ -257,76 +256,74 @@ export function DipoleAntennaCalculator() {
return <>
<Header as='h2'>Dipole Antenna</Header>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<ColoredInput fluid
label='MHz'
labelPosition='right'
value={mhzInputValue}
onChange={handleMhzChange}
onSelect={e => handleClick(e, 'mhz')}
color={mhzSelected ? null : 'grey'}
/>
</Grid.Column>
<Grid.Column>
<ColoredInput fluid
label='Wavelength'
labelPosition='right'
value={waveInputValue}
onChange={handleWaveChange}
onSelect={e => handleClick(e, 'wave')}
color={mhzSelected ? 'grey' : null}
/>
</Grid.Column>
</Grid.Row>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<ColoredInput fluid
label='MHz'
labelPosition='right'
value={mhzInputValue}
onChange={e => handleMhzChange(e.target.value)}
onSelect={e => handleClick(e, 'mhz')}
color={mhzSelected ? null : 'grey'}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<ColoredInput fluid
label='Wavelength'
labelPosition='right'
value={waveInputValue}
onChange={e => handleWaveChange(e.target.value)}
onSelect={e => handleClick(e, 'wave')}
color={mhzSelected ? 'grey' : null}
/>
</Grid.Col>
</Grid>
{ANTENNA_FRACTIONS.map(i => {
const [feet_, inches_] = convertFeetToFeetAndInches(feet * i.multiplier);
const [half_feet, half_inches] = convertFeetToFeetAndInches(feet * i.multiplier / 2);
return <Table key={i.name} size='large' striped unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell>{i.name}</TableHeaderCell>
<TableHeaderCell>Leg Length</TableHeaderCell>
</TableRow>
</TableHeader>
return <Table key={i.name} style={{marginTop: '1em'}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{i.name}</Table.HeaderCell>
<Table.HeaderCell>Leg Length</Table.HeaderCell>
</Table.Row>
</Table.Header>
<TableBody>
<TableRow>
<TableCell>
<Table.Body>
<Table.Row>
<Table.Cell>
{feet_} ft. {inches_} in.
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{half_feet} ft. {half_inches} in.
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(inches * i.multiplier)} inches
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(inches * i.multiplier / 2)} inches
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(meters * i.multiplier)} meters
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(meters * i.multiplier / 2)} meters
</TableCell>
</TableRow>
<TableRow>
<TableCell>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
{roundDigits(cm * i.multiplier)} cm.
</TableCell>
<TableCell>
</Table.Cell>
<Table.Cell>
{roundDigits(cm * i.multiplier / 2)} cm.
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
})}
</>

View file

@ -1,5 +1,4 @@
import React, {useContext, useState} from 'react';
import {Checkbox, Divider, Icon, Input, Radio, SegmentGroup} from "semantic-ui-react";
import React, {useState} from 'react';
import {
appendChecksum,
decryptOTP,
@ -12,22 +11,13 @@ import {
validateCharset
} from "../otp";
import {ErrorMessage, SimpleAccordion} from "../Common";
import {ThemeContext} from "../../contexts/contexts";
import {Button, Form, Header, Segment, TextArea} from "../Theme";
import {Button, Checkbox, Divider, Header, Panel, Radio, Stack, Status, Textarea, TextInput} from "../ui";
function Encrypt({chars, checksum}) {
// Encrypt happens live as the user types or pastes. Nothing leaves the browser.
const [otp, setOtp] = useState('');
const [plaintext, setPlaintext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setPlaintext(value);
}
};
let ciphertext = '';
let error = '';
if (otp && plaintext && chars) {
@ -42,34 +32,34 @@ function Encrypt({chars, checksum}) {
return <>
<Header as='h2'>Encrypt</Header>
<SegmentGroup>
<Segment>
<Stack gap='sm'>
<Panel>
<h3>Key</h3>
<TextArea
<Textarea
name='otp'
className='otp'
style={{width: '100%'}}
value={otp}
onChange={handleInputChange}
onChange={e => setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Plaintext</h3>
<TextArea
<Textarea
name='plaintext'
className='otp'
style={{width: '100%'}}
value={plaintext}
onChange={handleInputChange}
onChange={e => setPlaintext(e.currentTarget.value)}
placeholder='The message you want to send'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Ciphertext</h3>
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</Panel>
</Stack>
</>
}
@ -78,14 +68,6 @@ function Decrypt({chars, checksum}) {
const [otp, setOtp] = useState('');
const [ciphertext, setCiphertext] = useState('');
const handleInputChange = (event, {name, value}) => {
if (name === 'otp') {
setOtp(value);
} else {
setCiphertext(value);
}
};
let plaintext = '';
let error = '';
let checksumValid = null; // null = not applicable (checksum off or nothing to check)
@ -107,44 +89,42 @@ function Decrypt({chars, checksum}) {
return <>
<Header as='h2'>Decrypt</Header>
<SegmentGroup>
<Segment>
<Stack gap='sm'>
<Panel>
<h3>Key</h3>
<TextArea
<Textarea
name='otp'
className='otp'
style={{width: '100%'}}
value={otp}
onChange={handleInputChange}
onChange={e => setOtp(e.currentTarget.value)}
placeholder='The random letters from your One-Time Pad'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Ciphertext</h3>
<TextArea
<Textarea
name='ciphertext'
className='otp'
style={{width: '100%'}}
value={ciphertext}
onChange={handleInputChange}
onChange={e => setCiphertext(e.currentTarget.value)}
placeholder='The message you received'
/>
</Segment>
<Segment>
</Panel>
<Panel>
<h3>Plaintext</h3>
{checksumValid !== null && <p>
<Icon name={checksumValid ? 'check' : 'times'} color={checksumValid ? 'green' : 'red'}/>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</p>}
{checksumValid !== null &&
<Status kind={checksumValid ? 'complete' : 'failed'}>
{checksumValid ? 'Checksum is valid' : 'Checksum is invalid'}
</Status>}
{error
? <ErrorMessage>{error}</ErrorMessage>
: <pre>{plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}</pre>}
</Segment>
</SegmentGroup>
</Panel>
</Stack>
</>
}
export function OneTimePadCalculator() {
const {t} = useContext(ThemeContext);
// The character set is a property of the pad: encrypt, decrypt, and generation all use the same alphabet.
const [charsetKind, setCharsetKind] = useState('alphanumeric'); // 'alpha' | 'alphanumeric' | 'custom'
const [customChars, setCustomChars] = useState('');
@ -174,32 +154,28 @@ export function OneTimePadCalculator() {
<Header as='h1'>One-Time Pad</Header>
<Header as='h4'>One-Time Pads can be used to encrypt your communications. This can be done by hand
(yes, really) or in this app.</Header>
<p {...t}>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>Generate New Pad</Button>
<Button secondary href={cheatSheetURL}>Cheat Sheet PDF</Button>
<p>These messages are never stored and cannot be retrieved.</p>
<Button color='violet' disabled={!activeChars} onClick={handleGenerateNewPad}>
Generate New Pad
</Button>
<Button role='cancel' component='a' href={cheatSheetURL}>Cheat Sheet PDF</Button>
<SimpleAccordion title='Advanced'>
<Header as='h4'>Pad characters</Header>
<Form>
<Form.Field>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
</Form.Field>
<Form.Field>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
</Form.Field>
<Form.Field>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Form.Field>
</Form>
<Stack gap={4}>
<Radio label='A–Z' name='charset' value='alpha'
checked={charsetKind === 'alpha'} onChange={() => setCharsetKind('alpha')}/>
<Radio label='A–Z and 0–9' name='charset' value='alphanumeric'
checked={charsetKind === 'alphanumeric'} onChange={() => setCharsetKind('alphanumeric')}/>
<Radio label='Custom' name='charset' value='custom'
checked={charsetKind === 'custom'} onChange={() => setCharsetKind('custom')}/>
</Stack>
{charsetKind === 'custom' && <>
<Input
fluid
<TextInput
style={{width: '100%'}}
value={customChars}
placeholder='e.g. ABCDEF0123'
onChange={(e, {value}) => setCustomChars(value)}
onChange={e => setCustomChars(e.currentTarget.value)}
/>
{charsetError && <ErrorMessage>{charsetError}</ErrorMessage>}
</>}

View file

@ -1,5 +1,4 @@
import {Dropdown, MessageHeader, TableBody, TableCell, TableRow} from "semantic-ui-react";
import {Form, Header, Table} from "../Theme";
import {Group, Header, Label, NumberInput, Select, Table} from "../ui";
import React from "react";
import {HandPointMessage, roundDigits, useLocalStorage} from "../Common";
import {createUnit, multiply, unit} from "mathjs";
@ -91,65 +90,65 @@ function ratioReducer(prevState, e) {
const baseToUnitsMap = {
'length': [
{key: 'centimeter', value: 'centimeter', text: 'centimeter'},
{key: 'feet', value: 'feet', text: 'feet'},
{key: 'inch', value: 'inch', text: 'inch'},
{key: 'kilometer', value: 'kilometer', text: 'kilometer'},
{key: 'meter', value: 'meter', text: 'meter'},
{key: 'mile', value: 'mile', text: 'mile'},
{value: 'centimeter', text: 'centimeter'},
{value: 'feet', text: 'feet'},
{value: 'inch', text: 'inch'},
{value: 'kilometer', text: 'kilometer'},
{value: 'meter', text: 'meter'},
{value: 'mile', text: 'mile'},
],
'area': [
{key: 'm2', value: 'm2', text: 'm²'},
{key: 'sqin', value: 'sqin', text: 'square inch'},
{key: 'sqyd', value: 'sqyd', text: 'square yard'},
{key: 'sqmi', value: 'sqmi', text: 'square mile'},
{key: 'acre', value: 'acre', text: 'acre'},
{key: 'hectare', value: 'hectare', text: 'hectare'},
{value: 'm2', text: 'm²'},
{value: 'sqin', text: 'square inch'},
{value: 'sqyd', text: 'square yard'},
{value: 'sqmi', text: 'square mile'},
{value: 'acre', text: 'acre'},
{value: 'hectare', text: 'hectare'},
],
'volume': [
{key: 'cc', value: 'cc', text: 'cc'},
{key: 'liter', value: 'liter', text: 'liter'},
{key: 'm3', value: 'm3', text: 'meters³'},
{key: 'cup', value: 'cup', text: 'cup'},
{key: 'fluidounce', value: 'fluidounce', text: 'fl.oz'},
{key: 'gallon', value: 'gallon', text: 'gallon'},
{key: 'quart', value: 'quart', text: 'quart'},
{key: 'milliliter', value: 'milliliter', text: 'ml'},
{key: 'tablespoon', value: 'tablespoon', text: 'tablespoon'},
{key: 'teaspoon', value: 'teaspoon', text: 'teaspoon'},
{key: 'cuin', value: 'cuin', text: 'cuin'},
{value: 'cc', text: 'cc'},
{value: 'liter', text: 'liter'},
{value: 'm3', text: 'meters³'},
{value: 'cup', text: 'cup'},
{value: 'fluidounce', text: 'fl.oz'},
{value: 'gallon', text: 'gallon'},
{value: 'quart', text: 'quart'},
{value: 'milliliter', text: 'ml'},
{value: 'tablespoon', text: 'tablespoon'},
{value: 'teaspoon', text: 'teaspoon'},
{value: 'cuin', text: 'cuin'},
],
'mass': [
{key: 'grain', value: 'grain', text: 'grain'},
{key: 'gram', value: 'gram', text: 'gram'},
{key: 'lbs', value: 'lbs', text: 'pound'},
{key: 'stone', value: 'stone', text: 'stone'},
{key: 'ton', value: 'ton', text: 'ton'},
{value: 'grain', text: 'grain'},
{value: 'gram', text: 'gram'},
{value: 'lbs', text: 'pound'},
{value: 'stone', text: 'stone'},
{value: 'ton', text: 'ton'},
],
'energy': [
{key: 'joule', value: 'joule', text: 'joule'},
{key: 'Wh', value: 'Wh', text: 'Wh'},
{key: 'BTU', value: 'BTU', text: 'BTU'},
{key: 'watt', value: 'watt', text: 'watt'},
{key: 'hp', value: 'hp', text: 'hp'},
{value: 'joule', text: 'joule'},
{value: 'Wh', text: 'Wh'},
{value: 'BTU', text: 'BTU'},
{value: 'watt', text: 'watt'},
{value: 'hp', text: 'hp'},
],
};
// Used to get from unit like `BTU` back to the base `energy`.
let unitsToBaseMap = {};
for (const [base, units] of Object.entries(baseToUnitsMap)) {
for (const {key} of units) {
unitsToBaseMap[key] = base;
for (const {value} of units) {
unitsToBaseMap[value] = base;
}
}
const defaultUnits = {
// These will be replaced using local storage.
length: baseToUnitsMap.length[0]['key'], // centimeter
area: baseToUnitsMap.area[0]['key'], // m2
volume: baseToUnitsMap.volume[0]['key'], // cc
mass: baseToUnitsMap.mass[0]['key'], // grain
energy: baseToUnitsMap.energy[0]['key'], // joule
length: baseToUnitsMap.length[0]['value'], // centimeter
area: baseToUnitsMap.area[0]['value'], // m2
volume: baseToUnitsMap.volume[0]['value'], // cc
mass: baseToUnitsMap.mass[0]['value'], // grain
energy: baseToUnitsMap.energy[0]['value'], // joule
[null]: null, // nullUnit
};
@ -175,37 +174,40 @@ const unitToInputValue = (u) => {
return roundDigits(num, 3)
}
// "None" is represented by an empty string in the Select (which needs a string value);
// the reducer still receives/produces `null` for the base, same as before migration.
const baseOptions = [
{key: null, value: null, text: 'None'},
{key: 'length', value: 'length', label: 'meter, feet, inch, etc.', text: 'Length'},
{key: 'area', value: 'area', label: 'inch², m², etc.', text: 'Area'},
{key: 'volume', value: 'volume', label: 'liter, m³, cup, etc.', text: 'Volume'},
{key: 'mass', value: 'mass', label: 'gram, pound, etc.', text: 'Mass'},
{key: 'energy', value: 'energy', label: 'joule, watt, etc.', text: 'Energy'},
{value: '', label: 'None'},
{value: 'length', label: 'Length — meter, feet, inch, etc.'},
{value: 'area', label: 'Area — inch², m², etc.'},
{value: 'volume', label: 'Volume — liter, m³, cup, etc.'},
{value: 'mass', label: 'Mass — gram, pound, etc.'},
{value: 'energy', label: 'Energy — joule, watt, etc.'},
];
const RatioInput = React.forwardRef(({name, label, value, unitValue, unitName, unitOptions, color, dispatch, onKeyDown}, ref) => (
<div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${color}`}>{label}</div>
<input
<Group gap={0} wrap='nowrap' align='stretch' style={{marginBottom: '0.25em'}}>
<Label color={color || 'grey'}>{label}</Label>
<NumberInput
id={name}
name={name}
type="number"
ref={ref}
value={unitToInputValue(value)}
onChange={e => dispatch(e)}
onChange={val => dispatch({name, value: val})}
onKeyDown={onKeyDown}
onFocus={e => e.target.select()}
hideControls
style={{flex: 1}}
/>
{unitOptions &&
<Dropdown
options={unitOptions}
className='label'
onChange={(_, data) => dispatch(data)}
<Select
data={unitOptions.map(o => ({value: o.value, label: o.text}))}
onChange={val => dispatch({name: unitName, value: val})}
name={unitName}
value={unitValue}
style={{minWidth: 150}}
/>}
</div>
</Group>
));
const RatioCalculator = () => {
@ -239,16 +241,17 @@ const RatioCalculator = () => {
const unitOptions = baseToUnitsMap[state.base];
const getColor = (name) => state.lastUpdated.includes(name) ? '' : 'grey';
// Recently-updated inputs (the ones the user is actively typing) are highlighted;
// the others read as inactive/grey.
const getColor = (name) => state.lastUpdated.includes(name) ? 'blue' : 'grey';
const baseDropdown = <Dropdown selection
fluid
placeholder='Base Units'
options={baseOptions}
value={state.base}
name='base'
onChange={(_, data) => dispatch(data)}
style={{marginBottom: '1em'}}
const baseDropdown = <Select
placeholder='Base Units'
data={baseOptions}
value={state.base ?? ''}
name='base'
onChange={val => dispatch({name: 'base', value: val || null})}
style={{marginBottom: '1em'}}
/>;
const handleInputChange = (e) => {
@ -318,7 +321,7 @@ const RatioCalculator = () => {
}
}, []);
return <Form>
return <div>
<Header as='h1'>Ratio</Header>
<Header as='h2'>A : B = C : D</Header>
@ -335,37 +338,36 @@ const RatioCalculator = () => {
</Media>
<Media greaterThanOrEqual='tablet'>
<Table unstackable className='ratio-table'>
<TableBody>
<TableRow>
<TableCell className='equal-width'>{inputA}</TableCell>
<TableCell className='min-width'/>
<TableCell className='equal-width'>{inputC}</TableCell>
</TableRow>
<TableRow>
<TableCell className='equal-width'>
<Table className='ratio-table'>
<Table.Body>
<Table.Row>
<Table.Cell className='equal-width'>{inputA}</Table.Cell>
<Table.Cell className='min-width'/>
<Table.Cell className='equal-width'>{inputC}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className='equal-width'>
<hr/>
</TableCell>
<TableCell textAlign='center' className='min-width'>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}} className='min-width'>
<Header as='h1'>=</Header>
</TableCell>
<TableCell className='equal-width'>
</Table.Cell>
<Table.Cell className='equal-width'>
<hr/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className='equal-width'>{inputB}</TableCell>
<TableCell className='min-width'/>
<TableCell className='equal-width'>{inputD}</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className='equal-width'>{inputB}</Table.Cell>
<Table.Cell className='min-width'/>
<Table.Cell className='equal-width'>{inputD}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Media>
<HandPointMessage storageName='hint_ratio_calculator'>
<MessageHeader>Tip</MessageHeader>
You can change inputs using the <b>a</b>, <b>b</b>, <b>c</b>, and <b>d</b> keys.
<strong>Tip</strong> You can change inputs using the <b>a</b>, <b>b</b>, <b>c</b>, and <b>d</b> keys.
</HandPointMessage>
</Form>
</div>
}
export const RatioCalculators = () => {

View file

@ -1,12 +1,10 @@
import React from "react";
import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Button, Form, Header, Icon, Table} from "../Theme";
import {Button, Grid, Header, Table} from "../ui";
import {ColoredInput} from "../Apps";
import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
import {formatDuration} from "./WaterCalculator";
import {findNameKey, planSupplyPurchase} from "../inventory/summarize";
import {downloadCSV, inventoryExportFilename, shoppingListCSV} from "../inventory/inventoryExport";
import {ThemeContext} from "../../contexts/contexts";
// Field-role detection lives in inventory/summarize.js (single source of truth); re-exported here for back-compat.
export {findCaloriesKey, findCountKey} from "../inventory/summarize";
@ -81,10 +79,12 @@ export const RATION_PRESETS = {
const DEFAULT_PRESET = 'moderate';
// Category colors are theme tokens (not hex) so the tags stay legible — and become outlines instead of fills — in
// night mode.
const CATEGORY_META = [
{key: 'men', label: 'Men', color: '#4477aa'},
{key: 'women', label: 'Women', color: '#aa3377'},
{key: 'children', label: 'Children', color: '#228833'},
{key: 'men', label: 'Men', color: 'blue'},
{key: 'women', label: 'Women', color: 'pink'},
{key: 'children', label: 'Children', color: 'green'},
];
const DEMAND_INFO = 'Per-person daily calorie needs come from the Dietary Guidelines for Americans 2020-2025 '
@ -93,13 +93,6 @@ const DEMAND_INFO = 'Per-person daily calorie needs come from the Dietary Guidel
+ 'The Survival preset is a short-term emergency ration floor, below maintenance. All values are editable — '
+ 'adjust the household and activity level to see how long the stored food lasts.';
function textColorFor(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#000000' : '#ffffff';
}
function fmt(value, unit) {
if (!Number.isFinite(value) || value <= 0) {
return '—';
@ -130,61 +123,55 @@ export function RationEstimatePanel({name, items, fields, caloriesKey, countKey}
const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
const countInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={1} name={`count-${key}`} labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
<ColoredInput {...inputProps} min={0} step={1} name={`count-${key}`} label={label} color={color}
value={counts[key]} onChange={e => setCounts({...counts, [key]: e.target.value})}/>;
const rateInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={50} name={`rate-${key}`} labelPosition='left' value={rates[key]}
onChange={e => setRates({...rates, [key]: e.target.value})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
<ColoredInput {...inputProps} min={0} step={50} name={`rate-${key}`} label={label} color={color}
value={rates[key]} onChange={e => setRates({...rates, [key]: e.target.value})}/>;
return <Form>
return <div>
<Header as='h3'>People</Header>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
<Grid>
{CATEGORY_META.map(meta =>
<Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{countInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h3' style={{marginTop: '1em'}}>
Daily Calories (per person) <InfoPopup content={DEMAND_INFO}/>
</Header>
<div style={{marginBottom: '1em'}}>
<div style={{marginBottom: '1em', display: 'flex', flexWrap: 'wrap', gap: '0.5em'}}>
{Object.entries(RATION_PRESETS).map(([key, p]) => (
<button key={key} type='button' onClick={() => setPreset(key)} style={{
marginRight: '0.5em', padding: '0.4em 0.8em',
border: preset === key ? '2px solid #2185d0' : '1px solid #ccc',
background: preset === key ? '#e8f4fc' : 'white', borderRadius: '4px', cursor: 'pointer',
}}>
<Button key={key} role={preset === key ? 'primary' : 'cancel'} onClick={() => setPreset(key)}>
<strong>{p.label}</strong> — {p.description}
</button>
</Button>
))}
</div>
<Grid stackable columns={3}>
{CATEGORY_META.map(meta => <GridColumn key={meta.key}>{rateInput(meta)}</GridColumn>)}
<Grid>
{CATEGORY_META.map(meta =>
<Grid.Col key={meta.key} span={{base: 12, sm: 4}}>{rateInput(meta)}</Grid.Col>)}
</Grid>
<Table definition unstackable style={{marginTop: '1em'}}>
<TableBody>
<TableRow>
<TableCell width={6}>Total Stored Calories</TableCell>
<TableCell>{fmt(total, 'kcal')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Daily Demand</TableCell>
<TableCell>{fmt(daily, 'kcal/day')}</TableCell>
</TableRow>
<TableRow>
<TableCell>Food Lasts</TableCell>
<TableCell>{days ? formatDuration(days) : '—'}</TableCell>
</TableRow>
</TableBody>
<Table style={{marginTop: '1em'}}>
<Table.Body>
<Table.Row>
<Table.Cell>Total Stored Calories</Table.Cell>
<Table.Cell>{fmt(total, 'kcal')}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Daily Demand</Table.Cell>
<Table.Cell>{fmt(daily, 'kcal/day')}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Food Lasts</Table.Cell>
<Table.Cell>{days ? formatDuration(days) : '—'}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<SupplyPlan name={name} items={items} fields={fields} caloriesKey={caloriesKey} countKey={countKey}
currentDays={days} total={total} daily={daily}/>
</Form>;
</div>;
}
// Print only the supply-plan shopping list (not the whole-inventory print block): toggle a body class that the
@ -218,7 +205,6 @@ const PLAN_INFO = 'Drag the slider to a target duration longer than your current
* the additional packages to buy to reach it (the inventory scaled up proportionally — see planSupplyPurchase).
*/
function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, total, daily}) {
const {t} = React.useContext(ThemeContext);
const nameKey = findNameKey(fields);
const currentMonths = (currentDays || 0) / PLAN_MONTH_DAYS;
@ -252,62 +238,60 @@ function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, to
});
const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
const headerCell = (key, label) =>
<TableHeaderCell sorted={sortDir(key)} onClick={() => toggleSort(key)} style={{cursor: 'pointer'}}>
{label}
</TableHeaderCell>;
<Table.HeaderCell sorted={sortDir(key)} onSort={() => toggleSort(key)}>{label}</Table.HeaderCell>;
return <div style={{marginTop: '2em'}}>
<Header as='h3'>Plan More Supply <InfoPopup content={PLAN_INFO}/></Header>
{!countKey
? <p {...t}>Add a <strong>Count</strong> field to this inventory to plan purchases.</p>
? <p>Add a <strong>Count</strong> field to this inventory to plan purchases.</p>
: <>
<div style={{padding: '0 0.5em'}}>
<input type='range' min={minMonths} max={maxMonths} step={1} value={targetMonths}
aria-label='Target duration (months)' style={{width: '100%'}}
onChange={e => setTargetMonths(Number(e.target.value))}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>
<Header as='h2' style={{marginTop: '0.25em', textAlign: 'center'}}>
Target: {formatDuration(targetDays)}
</Header>
<p {...t} style={{...t.style, textAlign: 'center', opacity: 0.8}}>
<p style={{textAlign: 'center', opacity: 0.8}}>
Currently {formatDuration(currentDays)}
</p>
</div>
{rows.length === 0
? <p {...t}>Drag the slider above your current estimate to see a shopping list.</p>
? <p>Drag the slider above your current estimate to see a shopping list.</p>
: <>
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<Table className='shopping-list-table'>
<Table.Header>
<Table.Row>
{headerCell('name', 'Item')}
{headerCell('current', 'Have')}
{headerCell('additional', 'Buy')}
{headerCell('target', 'New Total')}
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.map((r, i) => <TableRow key={`${r.name}-${i}`}>
<TableCell>{r.name}</TableCell>
<TableCell>{r.current}</TableCell>
<TableCell><strong>+{r.additional}</strong></TableCell>
<TableCell>{r.target}</TableCell>
</TableRow>)}
</TableBody>
</Table.Row>
</Table.Header>
<Table.Body>
{sortedRows.map((r, i) => <Table.Row key={`${r.name}-${i}`}>
<Table.Cell>{r.name}</Table.Cell>
<Table.Cell>{r.current}</Table.Cell>
<Table.Cell><strong>+{r.additional}</strong></Table.Cell>
<Table.Cell>{r.target}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<p {...t}>
<p>
Buy <strong>{totalToBuy.toLocaleString()}</strong> additional package{totalToBuy === 1 ? '' : 's'}
{' '}across <strong>{rows.length}</strong> item{rows.length === 1 ? '' : 's'}
{projectedDays ? <> to reach about <strong>{formatDuration(projectedDays)}</strong> of food.</> : '.'}
</p>
<Button primary
<Button role='primary' icon='download'
onClick={() => downloadCSV(
inventoryExportFilename(`${name || 'inventory'} shopping list`, 'csv'),
shoppingListCSV(sortedRows))}>
<Icon name='download'/> Download CSV
Download CSV
</Button>
<Button onClick={printShoppingList}>
<Icon name='print'/> Print / Save as PDF
<Button icon='print' onClick={printShoppingList}>
Print / Save as PDF
</Button>
{/* Hidden printable block — `printShoppingList` makes this the only thing printed. */}

View file

@ -102,7 +102,7 @@ describe('RationEstimatePanel supply plan', () => {
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
// Scope to the on-screen table (the hidden print block duplicates these texts in the DOM).
const plan = within(document.querySelector('table.sortable'));
const plan = within(document.querySelector('table.shopping-list-table'));
expect(plan.getByText('Beans')).toBeTruthy();
expect(plan.getByText('+100')).toBeTruthy();
// Summary line reports the total to buy.
@ -118,13 +118,13 @@ describe('RationEstimatePanel supply plan', () => {
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
const itemNames = () =>
[...document.querySelectorAll('table.sortable tbody tr td:first-child')].map(td => td.textContent);
[...document.querySelectorAll('table.shopping-list-table tbody tr td:first-child')].map(td => td.textContent);
// Default sort is largest purchase first (Beans buys far more than Apples).
expect(itemNames()).toEqual(['Beans', 'Apples']);
// Sorting by Item ascending flips to alphabetical (scope to the sortable table; the print block also has an
// "Item" header).
fireEvent.click(within(document.querySelector('table.sortable')).getByText('Item'));
fireEvent.click(within(document.querySelector('table.shopping-list-table')).getByText('Item'));
expect(itemNames()).toEqual(['Apples', 'Beans']);
});

View file

@ -1,7 +1,5 @@
import React from "react";
import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {Form, Header, Segment, Table} from "../Theme";
import {Button, Grid, Group, Header, NumberInput, Panel, Table} from "../ui";
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
// Pure calculation functions for the Water Storage calculator.
@ -171,11 +169,13 @@ const DEMAND_INFO = 'Per-person daily water includes drinking plus basic hygiene
+ 'livestock, or other needs not covered by the per-person categories.';
// Per-category colors (Paul Tol bright palette) so the count inputs read at a glance.
// These are diagram/legend colors matched to a fixed visual identity per category —
// not themed UI chrome — so they stay literal hex.
const CATEGORY_META = [
{key: 'men', label: 'Men', icon: 'man', color: '#4477aa'},
{key: 'women', label: 'Women', icon: 'woman', color: '#aa3377'},
{key: 'children', label: 'Children', icon: 'child', color: '#228833'},
{key: 'pregnant', label: 'Pregnant/Nursing', icon: 'heart', color: '#ee6677'},
{key: 'men', label: 'Men', color: '#4477aa'},
{key: 'women', label: 'Women', color: '#aa3377'},
{key: 'children', label: 'Children', color: '#228833'},
{key: 'pregnant', label: 'Pregnant/Nursing', color: '#ee6677'},
];
// Pick black or white label text for legibility on a given background color.
@ -202,7 +202,21 @@ export function clampNonNegative(value) {
if (value === '') {
return '';
}
return value.startsWith('-') ? value.slice(1) : value;
return `${value}`.startsWith('-') ? `${value}`.slice(1) : value;
}
// A labeled numeric input whose label is tinted to match a category's diagram color.
// The color is a literal hex from CATEGORY_META, not a theme token, so this is a small
// local composition rather than the token-driven `Label`.
function SwatchInput({hex, label, value, onChange, name, min, step}) {
return <Group gap={0} wrap='nowrap' align='stretch' style={{marginBottom: '0.25em'}}>
<span style={{
display: 'flex', alignItems: 'center', padding: '0 10px', fontSize: 13, fontWeight: 500,
whiteSpace: 'nowrap', background: hex, color: textColorFor(hex), border: `1px solid ${hex}`,
}}>{label}</span>
<NumberInput name={name} value={value} onChange={onChange} min={min} step={step}
onFocus={e => e.target.select()} hideControls style={{flex: 1}}/>
</Group>;
}
export function WaterCalculator() {
@ -236,69 +250,51 @@ export function WaterCalculator() {
const total = totalWater(daily, days);
const weight = waterWeight(total, metric);
const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
// One count input per category, label colored to match.
const countInput = ({key, label, icon, color}) =>
<Input {...inputProps} min={0} step={1} name={`count-${key}`}
labelPosition='left' value={counts[key]}
onChange={e => setCounts({...counts, [key]: e.target.value === '' ? '' : String(Math.max(0, Math.round(Number(e.target.value))))})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
<i className={`${icon} icon`} style={{marginRight: '0.4em'}}/>{label}</Label>}/>;
const countInput = ({key, label, color}) =>
<SwatchInput hex={color} label={label} value={counts[key]} min={0} step={1} name={`count-${key}`}
onChange={value => setCounts({
...counts,
[key]: value === '' ? '' : String(Math.max(0, Math.round(Number(value)))),
})}/>;
// One editable rate input per category, sharing the category color.
const rateInput = ({key, label, color}) =>
<Input {...inputProps} min={0} step={0.1} name={`rate-${key}`}
labelPosition='left' value={rates[key]}
onChange={e => setRates({...rates, [key]: clampNonNegative(e.target.value)})}
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
{label}</Label>}/>;
<SwatchInput hex={color} label={label} value={rates[key]} min={0} step={0.1} name={`rate-${key}`}
onChange={value => setRates({...rates, [key]: clampNonNegative(value)})}/>;
const presetButtons = (
<div>
{Object.entries(WATER_PRESETS).map(([key, p]) => (
<button
key={key}
type="button"
onClick={() => setPreset(key)}
style={{
marginRight: '0.5em',
padding: '0.4em 0.8em',
border: preset === key ? '2px solid #2185d0' : '1px solid #ccc',
background: preset === key ? '#e8f4fc' : 'white',
borderRadius: '4px',
cursor: 'pointer'
}}
>
<strong>{p.label}</strong> — {p.description}
</button>
))}
<span style={{fontSize: '0.85em', opacity: 0.7, marginLeft: '0.5em'}}>
(switching resets rates to preset defaults)
</span>
</div>
);
const presetButtons = <Group gap='xs' style={{flexWrap: 'wrap'}}>
{Object.entries(WATER_PRESETS).map(([key, p]) => (
<Button key={key} type='button' role={preset === key ? 'primary' : 'cancel'}
onClick={() => setPreset(key)}>
{p.label} — {p.description}
</Button>
))}
<span style={{fontSize: '0.85em', opacity: 0.7}}>
(switching resets rates to preset defaults)
</span>
</Group>;
// One row per container: how many of it the total water would fill, and the spare
// capacity in the last one. Shown for every container so the user can compare.
const containerRows = CONTAINERS.map(c => {
const capacity = metric ? c.liters : c.gallons;
const need = total !== null ? containersNeeded(total, capacity) : null;
return <TableRow key={c.key}>
<TableCell>{c.label}</TableCell>
<TableCell>{capacity} {volumeUnit}</TableCell>
<TableCell>
return <Table.Row key={c.key}>
<Table.Cell>{c.label}</Table.Cell>
<Table.Cell>{capacity} {volumeUnit}</Table.Cell>
<Table.Cell>
{need === null
? '—'
: <span><b>{need.count}</b>
{need.leftover > 0 &&
<span style={{opacity: 0.7}}> ({fmt(need.leftover, volumeUnit)} spare)</span>}
</span>}
</TableCell>
</TableRow>;
</Table.Cell>
</Table.Row>;
});
return <Form>
return <div>
<Header as='h1'>Water Storage</Header>
<div style={{marginBottom: '1em'}}>
@ -308,25 +304,23 @@ export function WaterCalculator() {
<div style={{marginBottom: '1em'}}>{presetButtons}</div>
<Header as='h3'>People</Header>
<Grid stackable columns={2}>
<Grid>
{CATEGORY_META.map(meta =>
<GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
<Grid.Col key={meta.key} span={{base: 12, xs: 6}}>{countInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h3' style={{marginTop: '1em'}}>
Daily Use ({rateUnit} per person) <InfoPopup content={DEMAND_INFO}/>
</Header>
<Grid stackable columns={2}>
<Grid>
{CATEGORY_META.map(meta =>
<GridColumn key={meta.key}>{rateInput(meta)}</GridColumn>)}
<Grid.Col key={meta.key} span={{base: 12, xs: 6}}>{rateInput(meta)}</Grid.Col>)}
</Grid>
<Header as='h3' style={{marginTop: '1em'}}>Additional daily demand</Header>
<div style={{maxWidth: '320px'}}>
<Input {...inputProps} min={0} step={0.1} name="extra"
labelPosition='left' value={extra}
onChange={e => setExtra(clampNonNegative(e.target.value))}
label={<Label>Extra {rateUnit}</Label>}/>
<SwatchInput hex='#5f686a' label={`Extra ${rateUnit}`} value={extra} min={0} step={0.1} name="extra"
onChange={value => setExtra(clampNonNegative(value))}/>
<p style={{fontSize: '0.85em', opacity: 0.7, marginTop: '0.25em'}}>
Pets, livestock, guests, sick household members, or anything else not covered above.
</p>
@ -338,40 +332,40 @@ export function WaterCalculator() {
value={durationStop} aria-label='Duration'
onChange={e => setDurationIndex(Number(e.target.value))}
style={{width: '100%'}}/>
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>
<Header as='h2' style={{marginTop: '0.25em', textAlign: 'center'}}>
{formatDuration(days)}
</Header>
</div>
<Segment style={{marginTop: '1em'}}>
<Table definition unstackable>
<TableBody>
<TableRow>
<TableCell width={6}>Daily Use</TableCell>
<TableCell>{fmt(daily, `${volumeUnit}/day`)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Total Water for {formatDuration(days)}</TableCell>
<TableCell>{fmt(total, volumeUnit)}</TableCell>
</TableRow>
<TableRow>
<TableCell>Water Weight</TableCell>
<TableCell>{fmt(weight, weightUnit)}</TableCell>
</TableRow>
</TableBody>
<Panel style={{marginTop: '1em'}}>
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Daily Use</Table.Cell>
<Table.Cell>{fmt(daily, `${volumeUnit}/day`)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Total Water for {formatDuration(days)}</Table.Cell>
<Table.Cell>{fmt(total, volumeUnit)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{fontWeight: 600}}>Water Weight</Table.Cell>
<Table.Cell>{fmt(weight, weightUnit)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
<Header as='h3'>Containers Needed</Header>
<Table unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell>Container</TableHeaderCell>
<TableHeaderCell>Capacity</TableHeaderCell>
<TableHeaderCell>Needed</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>{containerRows}</TableBody>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Container</Table.HeaderCell>
<Table.HeaderCell>Capacity</Table.HeaderCell>
<Table.HeaderCell>Needed</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{containerRows}</Table.Body>
</Table>
</Segment>
</Form>;
</Panel>
</div>;
}

View file

@ -1,8 +1,6 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router';
import {Grid, Icon, Label, Progress, Button as SButton} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal, Table} from '../Theme';
import {Button, Icon, Label, Loading, Message, Modal, Progress, Stack, Table} from '../ui';
import {APIButton} from '../Common';
import {
executeBatchReorganization,
@ -200,72 +198,63 @@ export function BatchReorganizeModal({
if (!preview) return null;
return (
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<p>
<strong>{preview.total_collections}</strong> {kindLabelPlural.toLowerCase()} need reorganization,
with <strong>{preview.total_files_needing_move}</strong> total files to move.
</p>
<p>
<strong>New format:</strong> <Label>{preview.new_file_format}</Label>
</p>
</Grid.Column>
</Grid.Row>
<Stack>
<div>
<p>
<strong>{preview.total_collections}</strong> {kindLabelPlural.toLowerCase()} need reorganization,
with <strong>{preview.total_files_needing_move}</strong> total files to move.
</p>
<p>
<strong>New format:</strong> <Label>{preview.new_file_format}</Label>
</p>
</div>
{preview.collections && preview.collections.length > 0 && (
<Grid.Row>
<Grid.Column>
<strong>{kindLabelPlural} to reorganize:</strong>
<Table basic='very' compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{kindLabel}</Table.HeaderCell>
<Table.HeaderCell>Files to Move</Table.HeaderCell>
<Table.HeaderCell>Sample</Table.HeaderCell>
<div>
<strong>{kindLabelPlural} to reorganize:</strong>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{kindLabel}</Table.HeaderCell>
<Table.HeaderCell>Files to Move</Table.HeaderCell>
<Table.HeaderCell>Sample</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{preview.collections.slice(0, 10).map((collection, idx) => (
<Table.Row key={idx}>
<Table.Cell>{collection.collection_name}</Table.Cell>
<Table.Cell>
{collection.total_files}
</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all', fontSize: '0.9em'}}>
{collection.sample_move ? (
<>
<span style={{color: 'var(--muted)'}}>{collection.sample_move.old_path}</span>
<br/>
<Icon name='arrow right' size='small'/>
<span>{collection.sample_move.new_path}</span>
</>
) : '-'}
</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{preview.collections.slice(0, 10).map((collection, idx) => (
<Table.Row key={idx}>
<Table.Cell>{collection.collection_name}</Table.Cell>
<Table.Cell>
{collection.total_files}
</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all', fontSize: '0.9em'}}>
{collection.sample_move ? (
<>
<span style={{color: '#888'}}>{collection.sample_move.old_path}</span>
<br/>
<Icon name='arrow right' size='small'/>
<span>{collection.sample_move.new_path}</span>
</>
) : '-'}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
{preview.collections.length > 10 && (
<p style={{fontStyle: 'italic'}}>
...and {preview.collections.length - 10} more {kindLabelPlural.toLowerCase()}
</p>
)}
</Grid.Column>
</Grid.Row>
))}
</Table.Body>
</Table>
{preview.collections.length > 10 && (
<p style={{fontStyle: 'italic'}}>
...and {preview.collections.length - 10} more {kindLabelPlural.toLowerCase()}
</p>
)}
</div>
)}
{preview.total_collections === 0 && (
<Grid.Row>
<Grid.Column>
<Message info>
<Message.Header>No {kindLabelPlural} Need Reorganization</Message.Header>
<p>All {kindLabelPlural.toLowerCase()} are already organized according to the current format.</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='info' title={`No ${kindLabelPlural} Need Reorganization`}>
<p>All {kindLabelPlural.toLowerCase()} are already organized according to the current format.</p>
</Message>
)}
</Grid>
</Stack>
);
};
@ -273,83 +262,60 @@ export function BatchReorganizeModal({
if (!status) return null;
const currentCollection = status.current_collection;
const overallColor = status.status === 'complete' ? 'green' : status.status === 'failed' ? 'red' : 'blue';
return (
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<p>
<strong>Overall Status:</strong> {status.status}
</p>
<strong>Overall Progress:</strong>
<Progress
percent={status.overall_percent || 0}
progress
indicating={status.status !== 'complete' && status.status !== 'failed'}
success={status.status === 'complete'}
error={status.status === 'failed'}
/>
<p>
{status.completed_collections || 0} of {status.total_collections || 0} {kindLabelPlural.toLowerCase()} completed
</p>
</Grid.Column>
</Grid.Row>
<Stack>
<div>
<p>
<strong>Overall Status:</strong> {status.status}
</p>
<strong>Overall Progress:</strong>
<Progress percent={status.overall_percent || 0} color={overallColor}/>
<p>
{status.completed_collections || 0} of {status.total_collections || 0} {kindLabelPlural.toLowerCase()} completed
</p>
</div>
{currentCollection && (
<Grid.Row>
<Grid.Column>
<strong>Currently Processing:</strong> {currentCollection.name}
<Progress
percent={currentCollection.percent || 0}
progress
indicating
size='small'
color='blue'
/>
<p style={{fontSize: '0.9em'}}>
{currentCollection.completed || 0} of {currentCollection.total || 0} files
</p>
</Grid.Column>
</Grid.Row>
<div>
<strong>Currently Processing:</strong> {currentCollection.name}
<Progress percent={currentCollection.percent || 0} color='blue'/>
<p style={{fontSize: '0.9em'}}>
{currentCollection.completed || 0} of {currentCollection.total || 0} files
</p>
</div>
)}
{status.status === 'complete' && (
<Grid.Row>
<Grid.Column>
<Message success>
<Message.Header>Batch Reorganization Complete</Message.Header>
<p>All {kindLabelPlural.toLowerCase()} have been reorganized successfully.</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='success' title='Batch Reorganization Complete'>
<p>All {kindLabelPlural.toLowerCase()} have been reorganized successfully.</p>
</Message>
)}
{status.status === 'failed' && status.failed_collection && (
<Grid.Row>
<Grid.Column>
<Message negative>
<Message.Header>Reorganization Failed</Message.Header>
<p>
Failed on {kindLabel.toLowerCase()}: <strong>{status.failed_collection.name}</strong>
</p>
<SButton
as={Link}
to={kind === 'channel'
? `/videos/channel/${status.failed_collection.channel_id || status.failed_collection.id}/edit`
: `/archives/domain/${status.failed_collection.id}/edit`
}
size='small'
>
<Icon name='eye'/> View {kindLabel}
</SButton>
{status.error && (
<p style={{whiteSpace: 'pre-wrap'}}>Error: {status.error}</p>
)}
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='error' title='Reorganization Failed'>
<p>
Failed on {kindLabel.toLowerCase()}: <strong>{status.failed_collection.name}</strong>
</p>
<Button
component={Link}
to={kind === 'channel'
? `/videos/channel/${status.failed_collection.channel_id || status.failed_collection.id}/edit`
: `/archives/domain/${status.failed_collection.id}/edit`
}
role='cancel'
icon='eye'
size='sm'
>
View {kindLabel}
</Button>
{status.error && (
<p style={{whiteSpace: 'pre-wrap'}}>Error: {status.error}</p>
)}
</Message>
)}
</Grid>
</Stack>
);
};
@ -361,7 +327,7 @@ export function BatchReorganizeModal({
open={open}
onClose={handleClose}
closeIcon={true}
closeOnDimmerClick={!isInProgress}
closeOnClickOutside={!isInProgress}
closeOnEscape={!isInProgress}
size='large'
>
@ -369,19 +335,10 @@ export function BatchReorganizeModal({
Reorganize All {kindLabelPlural}
</Modal.Header>
<Modal.Content scrolling>
{loading && (
<Message icon>
<Icon name='circle notched' loading/>
<Message.Content>
<Message.Header>Loading Preview</Message.Header>
Analyzing {kindLabelPlural.toLowerCase()}...
</Message.Content>
</Message>
)}
{loading && <Loading>Analyzing {kindLabelPlural.toLowerCase()}...</Loading>}
{error && !status?.failed_collection && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p>{error}</p>
</Message>
)}
@ -390,12 +347,12 @@ export function BatchReorganizeModal({
{batchJobId && renderProgress()}
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClose}>
<Button role='cancel' onClick={handleClose}>
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
</Button>
{canReorganize && (
<APIButton
color='violet'
role='primary'
onClick={handleReorganize}
obeyWROLMode={true}
>

View file

@ -68,7 +68,7 @@ describe('BatchReorganizeModal', () => {
renderWithProviders(<BatchReorganizeModal {...defaultProps} />);
// Should show loading initially
expect(screen.getByText(/Loading Preview/i)).toBeInTheDocument();
expect(screen.getByText(/Analyzing channels/i)).toBeInTheDocument();
// Wait for preview to load
await waitFor(() => {

View file

@ -1,7 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Button as SButton, Grid, Icon, Label, Progress} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal, Table} from '../Theme';
import {Button, Icon, Label, Loading, Message, Modal, Progress, Stack, Table} from '../ui';
import {APIButton} from '../Common';
import {
executeCollectionReorganization,
@ -271,154 +269,124 @@ export function CollectionReorganizeModal({
if (!preview) return null;
return (
<Grid columns={1}>
<Stack>
{/* Warning when collection appears organized but has files to move */}
{preview.files_needing_move > 0 && !needsReorganization && (
<Grid.Row>
<Grid.Column>
<Message warning>
<Message.Header>Collection Appears Organized</Message.Header>
<p>
This collection's format matches the current configuration, but
{' '}<strong>{preview.files_needing_move}</strong> files don't match the expected
layout.
This can happen if a previous reorganization was interrupted.
</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='warning' title='Collection Appears Organized'>
<p>
This collection's format matches the current configuration, but
{' '}<strong>{preview.files_needing_move}</strong> files don't match the expected
layout.
This can happen if a previous reorganization was interrupted.
</p>
</Message>
)}
<Grid.Row>
<Grid.Column>
<div>
<p>
<strong>{preview.files_needing_move}</strong> of <strong>{preview.total_files}</strong> files
will be reorganized.
</p>
{preview.current_file_format && (
<p>
<strong>{preview.files_needing_move}</strong> of <strong>{preview.total_files}</strong> files
will be reorganized.
<strong>Current format:</strong> <code>{preview.current_file_format}</code>
</p>
{preview.current_file_format && (
<p>
<strong>Current format:</strong> <code>{preview.current_file_format}</code>
</p>
)}
<p>
<strong>New format:</strong> <Label>{preview.new_file_format}</Label>
</p>
</Grid.Column>
</Grid.Row>
)}
<p>
<strong>New format:</strong> <Label>{preview.new_file_format}</Label>
</p>
</div>
{/* Conflict warning */}
{preview.has_conflicts && (
<Grid.Row>
<Grid.Column>
<Message error>
<Message.Header>
<Icon name='warning sign'/> Conflicts Detected
</Message.Header>
<p>
<strong>{preview.conflicts.length}</strong> destination{' '}
{preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
multiple files that would collide.
You must resolve these conflicts before reorganizing.
</p>
<SButton
color='yellow'
onClick={handleResolveConflicts}
loading={loadingConflicts}
disabled={loadingConflicts}
>
<Icon name='wrench'/> Resolve Conflicts
</SButton>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='error' icon='warning sign' title='Conflicts Detected'>
<p>
<strong>{preview.conflicts.length}</strong> destination{' '}
{preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
multiple files that would collide.
You must resolve these conflicts before reorganizing.
</p>
<Button
role='retry'
icon='wrench'
onClick={handleResolveConflicts}
loading={loadingConflicts}
disabled={loadingConflicts}
>
Resolve Conflicts
</Button>
</Message>
)}
{preview.sample_moves && preview.sample_moves.length > 0 && (
<Grid.Row>
<Grid.Column>
<strong>Sample moves:</strong>
<Table basic='very' compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Current Path</Table.HeaderCell>
<Table.HeaderCell/>
<Table.HeaderCell>New Path</Table.HeaderCell>
<div>
<strong>Sample moves:</strong>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Current Path</Table.HeaderCell>
<Table.HeaderCell/>
<Table.HeaderCell>New Path</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{preview.sample_moves.map((move, idx) => (
<Table.Row key={idx}>
<Table.Cell style={{wordBreak: 'break-all'}}>
{move.old_path}
</Table.Cell>
<Table.Cell>
<Icon name='arrow right'/>
</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all'}}>
{move.new_path}
</Table.Cell>
</Table.Row>
</Table.Header>
<Table.Body>
{preview.sample_moves.map((move, idx) => (
<Table.Row key={idx}>
<Table.Cell style={{wordBreak: 'break-all'}}>
{move.old_path}
</Table.Cell>
<Table.Cell>
<Icon name='arrow right'/>
</Table.Cell>
<Table.Cell style={{wordBreak: 'break-all'}}>
{move.new_path}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
{preview.files_needing_move > preview.sample_moves.length && (
<p style={{fontStyle: 'italic'}}>
...and {preview.files_needing_move - preview.sample_moves.length} more files
</p>
)}
</Grid.Column>
</Grid.Row>
))}
</Table.Body>
</Table>
{preview.files_needing_move > preview.sample_moves.length && (
<p style={{fontStyle: 'italic'}}>
...and {preview.files_needing_move - preview.sample_moves.length} more files
</p>
)}
</div>
)}
{preview.files_needing_move === 0 && (
<Grid.Row>
<Grid.Column>
<Message info>
<Message.Header>No Files Need Reorganization</Message.Header>
<p>
All files are already organized according to the current format.
{needsReorganization && ' Click "Mark as Organized" to dismiss the warning.'}
</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='info' title='No Files Need Reorganization'>
<p>
All files are already organized according to the current format.
{needsReorganization && ' Click "Mark as Organized" to dismiss the warning.'}
</p>
</Message>
)}
</Grid>
</Stack>
);
};
const renderProgress = () => {
if (!status) return null;
const progressColor = status.status === 'complete' ? 'green' : status.status === 'error' ? 'red' : 'blue';
return (
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<p>
<strong>Status:</strong> {status.status}
</p>
<Progress
percent={status.percent || 0}
progress
indicating={status.status !== 'complete'}
success={status.status === 'complete'}
/>
<p>
{status.completed || 0} of {status.total || 0} files processed
</p>
</Grid.Column>
</Grid.Row>
<Stack>
<div>
<p>
<strong>Status:</strong> {status.status}
</p>
<Progress percent={status.percent || 0} color={progressColor}/>
<p>
{status.completed || 0} of {status.total || 0} files processed
</p>
</div>
{status.status === 'complete' && (
<Grid.Row>
<Grid.Column>
<Message success>
<Message.Header>Reorganization Complete</Message.Header>
<p>All files have been reorganized successfully.</p>
</Message>
</Grid.Column>
</Grid.Row>
<Message kind='success' title='Reorganization Complete'>
<p>All files have been reorganized successfully.</p>
</Message>
)}
</Grid>
</Stack>
);
};
@ -433,7 +401,7 @@ export function CollectionReorganizeModal({
open={open}
onClose={handleClose}
closeIcon={true}
closeOnDimmerClick={!isInProgress}
closeOnClickOutside={!isInProgress}
closeOnEscape={!isInProgress}
size='fullscreen'
>
@ -441,19 +409,10 @@ export function CollectionReorganizeModal({
Reorganize Files: {collectionName}
</Modal.Header>
<Modal.Content>
{loading && (
<Message icon>
<Icon name='circle notched' loading/>
<Message.Content>
<Message.Header>Loading Preview</Message.Header>
Analyzing files...
</Message.Content>
</Message>
)}
{loading && <Loading>Analyzing files...</Loading>}
{error && (
<Message negative>
<Message.Header>Error</Message.Header>
<Message kind='error' title='Error'>
<p style={{whiteSpace: 'pre-wrap'}}>{error}</p>
</Message>
)}
@ -462,12 +421,12 @@ export function CollectionReorganizeModal({
{status && renderProgress()}
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClose}>
<Button role='cancel' onClick={handleClose}>
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
</Button>
{canReorganize && (
<APIButton
color='violet'
role='primary'
onClick={handleReorganize}
obeyWROLMode={true}
>
@ -476,7 +435,7 @@ export function CollectionReorganizeModal({
)}
{canMarkAsOrganized && (
<APIButton
color='green'
role='save'
onClick={handleReorganize}
obeyWROLMode={true}
>

View file

@ -65,7 +65,7 @@ describe('CollectionReorganizeModal', () => {
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
// Should show loading initially
expect(screen.getByText(/Loading Preview/i)).toBeInTheDocument();
expect(screen.getByText(/Analyzing files/i)).toBeInTheDocument();
// Wait for preview to load
await waitFor(() => {

View file

@ -1,11 +1,10 @@
import React, {useContext} from 'react';
import {Link} from 'react-router';
import {Message, PlaceholderHeader, PlaceholderLine, TableCell, TableRow} from 'semantic-ui-react';
import {Placeholder, Table} from '../Theme';
import {Button, Message, Placeholder, Table} from '../ui';
import _ from 'lodash';
import {SortableTable} from '../SortableTable';
import {formatFrequency, humanFileSize} from '../Common';
import {Media, ThemeContext} from '../../contexts/contexts';
import {Media} from '../../contexts/contexts';
import {TagsContext} from '../../Tags';
/**
@ -46,17 +45,25 @@ function getCollectionSearchLink(collection, routes, primaryKey) {
return null;
}
/**
* Convert a Semantic-style relative column width (1-16) into a CSS percentage.
* @param {number} width - Relative width, 1-16
* @returns {Object|undefined} A style object, or undefined when no width is configured
*/
function widthStyle(width) {
return width ? {width: `${(width / 16) * 100}%`} : undefined;
}
/**
* Render a single table row for desktop view.
*
* @param {Object} collection - The collection data
* @param {Array} columns - Column configurations
* @param {Object} routes - Routes configuration
* @param {string} inverted - Inverted theme class
* @param {Function} SingleTag - Tag component from context
* @param {Function} onRowClick - Optional click handler
*/
function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick) {
function renderRow(collection, columns, routes, SingleTag, onRowClick) {
const cells = columns.map((col) => {
let value = collection[col.key];
@ -64,9 +71,8 @@ function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)
if (col.type === 'actions') {
const idField = getIdField(routes);
const editRoute = routes?.edit?.replace(':id', collection[idField]);
const buttonClass = `ui button secondary ${inverted}`;
return <Table.Cell key={col.key} textAlign={col.align || 'right'} width={col.width}>
{editRoute && <Link className={buttonClass} to={editRoute}>Edit</Link>}
return <Table.Cell key={col.key} style={{textAlign: col.align || 'right', ...widthStyle(col.width)}}>
{editRoute && <Button role='cancel' component={Link} to={editRoute} size='sm'>Edit</Button>}
</Table.Cell>;
}
@ -90,7 +96,7 @@ function renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)
}
}
return <Table.Cell key={col.key} textAlign={col.align || 'left'} width={col.width}>
return <Table.Cell key={col.key} style={{textAlign: col.align || 'left', ...widthStyle(col.width)}}>
{value || '-'}
</Table.Cell>;
});
@ -114,8 +120,8 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
const editRoute = routes?.edit?.replace(':id', collection[idField]);
const searchLink = getCollectionSearchLink(collection, routes, primaryColumn.key);
return <TableRow verticalAlign='top'>
<TableCell>
return <Table.Row style={{verticalAlign: 'top'}}>
<Table.Cell>
{searchLink ? (
<Link to={searchLink}>
<strong>{collection[primaryColumn.key]}</strong>
@ -140,11 +146,11 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
);
})
}
</TableCell>
<TableCell textAlign='right'>
{editRoute && <Link className="ui button secondary" to={editRoute}>Edit</Link>}
</TableCell>
</TableRow>;
</Table.Cell>
<Table.Cell style={{textAlign: 'right'}}>
{editRoute && <Button role='cancel' component={Link} to={editRoute} size='sm'>Edit</Button>}
</Table.Cell>
</Table.Row>;
}
/**
@ -165,38 +171,26 @@ export function CollectionTable({
onRowClick,
emptyMessage = 'No items yet'
}) {
const {inverted} = useContext(ThemeContext);
const {SingleTag} = useContext(TagsContext);
// Loading state
if (collections === null) {
return <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
return <Placeholder lines={2}/>;
}
// Error state
if (collections === undefined) {
return <Message error>
<Message.Header>Could not fetch collections</Message.Header>
</Message>;
return <Message kind='error' title='Could not fetch collections'/>;
}
// Empty state
if (collections && collections.length === 0) {
return <Message>
<Message.Header>{emptyMessage}</Message.Header>
</Message>;
return <Message title={emptyMessage}/>;
}
// No columns configured
if (!columns || columns.length === 0) {
return <Message warning>
<Message.Header>No columns configured</Message.Header>
</Message>;
return <Message kind='warning' title='No columns configured'/>;
}
// Filter collections by search string
@ -244,7 +238,7 @@ export function CollectionTable({
return <>
<Media at='mobile'>
<SortableTable
tableProps={{striped: true, size: 'small', unstackable: true}}
tableProps={{striped: true, size: 'sm'}}
data={filteredCollections}
rowFunc={(collection) => <MobileCollectionRow key={collection.id} collection={collection}
mobileColumns={mobileColumns} routes={routes}/>}
@ -255,9 +249,9 @@ export function CollectionTable({
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{striped: true, size: 'large', unstackable: true, compact: true}}
tableProps={{striped: true, size: 'lg', verticalSpacing: 'xs'}}
data={filteredCollections}
rowFunc={(collection) => renderRow(collection, columns, routes, inverted, SingleTag, onRowClick)}
rowFunc={(collection) => renderRow(collection, columns, routes, SingleTag, onRowClick)}
rowKey='id'
tableHeaders={headers}
defaultSortColumn={defaultSortColumn}

View file

@ -26,23 +26,28 @@ jest.mock('../../contexts/contexts', () => ({
},
}));
// Mock SortableTable to simplify testing
jest.mock('../SortableTable', () => ({
SortableTable: ({data, rowFunc, tableHeaders}) => (
<table data-testid="sortable-table">
<thead>
<tr>
{tableHeaders.map(h => (
<th key={h.key} data-width={h.width}>{h.text}</th>
))}
</tr>
</thead>
<tbody>
{data.map(item => rowFunc(item))}
</tbody>
</table>
),
}));
// Mock SortableTable to simplify testing. Uses the real `Table` compound
// components (rather than raw <table>/<tr>/<td>) because `rowFunc` returns
// `Table.Row`/`Table.Cell` elements that read Mantine's table context.
jest.mock('../SortableTable', () => {
const {Table} = jest.requireActual('../ui');
return {
SortableTable: ({data, rowFunc, tableHeaders}) => (
<Table data-testid="sortable-table">
<Table.Header>
<Table.Row>
{tableHeaders.map(h => (
<Table.HeaderCell key={h.key} data-width={h.width}>{h.text}</Table.HeaderCell>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map(item => rowFunc(item))}
</Table.Body>
</Table>
),
};
});
// Test column and routes configurations
const DOMAIN_COLUMNS = [
@ -73,7 +78,7 @@ describe('CollectionTable', () => {
/>
);
expect(container.querySelector('.ui.placeholder')).toBeInTheDocument();
expect(container.querySelector('.mantine-Skeleton-root')).toBeInTheDocument();
});
it('renders error message when collections is undefined', () => {

View file

@ -1,7 +1,6 @@
import React, {useEffect, useState} from 'react';
import {Card, Grid, Icon, Image, Label, Segment, Button as SButton} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal} from '../Theme';
import {IconVideo} from '@tabler/icons-react';
import {Button, Icon, IconButton, Label, Message, Modal, Panel, Stack} from '../ui';
import {deleteFileGroups} from '../../api';
import {humanFileSize, isoDatetimeToString} from '../Common';
import {TaggedDeleteConfirmModal} from '../TaggedDeleteConfirmModal';
@ -57,92 +56,91 @@ function ConflictFileCard({file, onDelete, isRecommended}) {
};
return (
<Card fluid>
<Card.Content>
{localError && (
<Message negative size='small' style={{marginBottom: '0.5em'}}>
<Icon name='warning'/> {localError}
<Panel style={{marginBottom: '0.5em'}}>
{localError && (
<div style={{marginBottom: '0.5em'}}>
<Message kind='error' icon='warning'>
{localError}
</Message>
)}
<Grid>
<Grid.Row>
{/* Poster/Thumbnail column */}
<Grid.Column width={4}>
{file.poster_path ? (
<Image
src={`/media/${file.poster_path}`}
size='small'
style={{maxHeight: '100px', objectFit: 'cover'}}
/>
) : (
<Segment placeholder style={{height: '80px'}}>
<Icon
name={file.model_type === 'video' ? 'video' : 'file'}
size='large'
disabled
/>
</Segment>
)}
</Grid.Column>
</div>
)}
<div style={{display: 'flex', gap: '1em', alignItems: 'center'}}>
{/* Poster/Thumbnail column */}
<div style={{flex: '0 0 100px'}}>
{file.poster_path ? (
<img
src={`/media/${file.poster_path}`}
alt=''
style={{width: '100%', height: '80px', objectFit: 'cover'}}
/>
) : (
<div style={{
height: '80px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--border)',
color: 'var(--muted)',
}}>
{file.model_type === 'video'
? <Icon component={IconVideo} size='large'/>
: <Icon name='file' size='large'/>}
</div>
)}
</div>
{/* File info column */}
<Grid.Column width={10}>
<Card.Header style={{marginBottom: '0.5em'}}>
{file.title || 'Untitled'}
{isRecommended && (
<Label color='green' size='small' style={{marginLeft: '0.5em'}}>
<Icon name='star'/> Recommended to Keep
</Label>
)}
</Card.Header>
<Card.Meta style={{wordBreak: 'break-all'}}>
<Icon name='folder open outline'/> {file.current_path}
</Card.Meta>
<Card.Description>
<div style={{marginTop: '0.5em'}}>
{file.size > 0 && (
<Label size='small'>
<Icon name='hdd'/> {humanFileSize(file.size)}
</Label>
)}
{file.published_datetime && (
<Label size='small'>
<Icon name='calendar'/> {isoDatetimeToString(file.published_datetime)}
</Label>
)}
{file.source_id && (
<Label size='small'>
<Icon name='linkify'/> {file.source_id}
</Label>
)}
{file.quality_rank !== null && file.quality_rank !== undefined && (
<Label size='small' color='purple'>
<Icon name='star outline'/> {file.quality_rank}
</Label>
)}
<Label size='small' color={file.model_type === 'video' ? 'blue' : 'orange'}>
{file.model_type}
</Label>
</div>
</Card.Description>
</Grid.Column>
{/* File info column */}
<div style={{flex: '1 1 auto', minWidth: 0}}>
<div style={{marginBottom: '0.5em', fontWeight: 600}}>
{file.title || 'Untitled'}
{isRecommended && (
<Label color='green' style={{marginLeft: '0.5em'}}>
<Icon name='star'/> Recommended to Keep
</Label>
)}
</div>
<div style={{wordBreak: 'break-all', color: 'var(--muted)', fontSize: '0.9em'}}>
<Icon name='folder open outline'/> {file.current_path}
</div>
<div style={{marginTop: '0.5em', display: 'flex', flexWrap: 'wrap', gap: '0.4em'}}>
{file.size > 0 && (
<Label>
<Icon name='hdd'/> {humanFileSize(file.size)}
</Label>
)}
{file.published_datetime && (
<Label>
<Icon name='calendar'/> {isoDatetimeToString(file.published_datetime)}
</Label>
)}
{file.source_id && (
<Label>
<Icon name='linkify'/> {file.source_id}
</Label>
)}
{file.quality_rank !== null && file.quality_rank !== undefined && (
<Label color='violet'>
<Icon name='star outline'/> {file.quality_rank}
</Label>
)}
<Label color={file.model_type === 'video' ? 'blue' : 'orange'}>
{file.model_type}
</Label>
</div>
</div>
{/* Delete button column */}
<Grid.Column width={2} textAlign='center' verticalAlign='middle'>
<SButton
color='red'
icon
loading={isDeleting}
disabled={isDeleting}
onClick={() => handleDelete(false)}
title='Delete this file'
>
<Icon name='trash'/>
</SButton>
</Grid.Column>
</Grid.Row>
</Grid>
</Card.Content>
{/* Delete button column */}
<div style={{flex: '0 0 auto'}}>
<IconButton
role='danger'
icon='trash'
label='Delete this file'
loading={isDeleting}
disabled={isDeleting}
onClick={() => handleDelete(false)}
/>
</div>
</div>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
taggedFileGroups={taggedFileGroups}
@ -152,7 +150,7 @@ function ConflictFileCard({file, onDelete, isRecommended}) {
await handleDelete(true);
}}
/>
</Card>
</Panel>
);
}
@ -212,7 +210,6 @@ export function ConflictResolutionModal({
};
const totalConflicts = localConflicts.length;
const totalFiles = localConflicts.reduce((sum, c) => sum + c.conflicting_files.length, 0);
return (
<Modal
@ -222,11 +219,10 @@ export function ConflictResolutionModal({
size='fullscreen'
>
<Modal.Header>
<Icon name='warning sign' color='yellow'/> Resolve Conflicts Before Reorganizing
<Icon name='warning sign'/> Resolve Conflicts Before Reorganizing
</Modal.Header>
<Modal.Content scrolling>
<Message warning>
<Message.Header>Destination Path Conflicts Detected</Message.Header>
<Message kind='warning' title='Destination Path Conflicts Detected'>
<p>
{totalConflicts} destination {totalConflicts === 1 ? 'path has' : 'paths have'}{' '}
multiple files that would be moved there. This typically happens when the same{' '}
@ -238,56 +234,58 @@ export function ConflictResolutionModal({
</p>
</Message>
{localConflicts.map((conflict, idx) => {
// Find the highest quality rank in this conflict (for video recommendations)
const hasRanks = conflict.conflicting_files.some(
f => f.quality_rank !== null && f.quality_rank !== undefined
);
const highestRank = hasRanks
? Math.max(...conflict.conflicting_files.map(f => f.quality_rank || 0))
: null;
// Check if there are multiple files with the same highest rank
const highestRankCount = hasRanks
? conflict.conflicting_files.filter(f => f.quality_rank === highestRank).length
: 0;
<Stack>
{localConflicts.map((conflict, idx) => {
// Find the highest quality rank in this conflict (for video recommendations)
const hasRanks = conflict.conflicting_files.some(
f => f.quality_rank !== null && f.quality_rank !== undefined
);
const highestRank = hasRanks
? Math.max(...conflict.conflicting_files.map(f => f.quality_rank || 0))
: null;
// Check if there are multiple files with the same highest rank
const highestRankCount = hasRanks
? conflict.conflicting_files.filter(f => f.quality_rank === highestRank).length
: 0;
return (
<Segment key={idx}>
<Label attached='top' color='grey'>
<Icon name='folder'/> Destination: {conflict.destination_path}
<Label.Detail>{conflict.conflicting_files.length} files</Label.Detail>
</Label>
return (
<Panel key={idx}>
<div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '1em'}}>
<Icon name='folder'/>
<strong>Destination: {conflict.destination_path}</strong>
<Label>{conflict.conflicting_files.length} files</Label>
</div>
<div style={{marginTop: '1em'}}>
{conflict.conflicting_files.map((file, fileIdx) => {
// Only show "Recommended" if there's a clear winner (one file with highest rank)
const isRecommended = hasRanks &&
highestRankCount === 1 &&
file.quality_rank === highestRank;
<div>
{conflict.conflicting_files.map((file, fileIdx) => {
// Only show "Recommended" if there's a clear winner (one file with highest rank)
const isRecommended = hasRanks &&
highestRankCount === 1 &&
file.quality_rank === highestRank;
return (
<ConflictFileCard
key={file.file_group_id}
file={file}
onDelete={handleFileDelete}
isRecommended={isRecommended}
/>
);
})}
</div>
</Segment>
);
})}
return (
<ConflictFileCard
key={file.file_group_id}
file={file}
onDelete={handleFileDelete}
isRecommended={isRecommended}
/>
);
})}
</div>
</Panel>
);
})}
</Stack>
{localConflicts.length === 0 && (
<Message success>
<Message.Header>All Conflicts Resolved</Message.Header>
<Message kind='success' title='All Conflicts Resolved'>
<p>You can now proceed with the reorganization.</p>
</Message>
)}
</Modal.Content>
<Modal.Actions>
<Button onClick={handleClose}>
<Button role='cancel' onClick={handleClose}>
{localConflicts.length === 0 ? 'Done' : 'Close'}
</Button>
</Modal.Actions>

View file

@ -1,5 +1,5 @@
import React from "react";
import {Input, Select} from "semantic-ui-react";
import {Select, TextInput} from "../ui";
import {ALL_UNITS, evaluateExpression, NUMERIC_TYPES, UNIT_GROUPS} from "./units";
// Renders the correct editor for a single field/value, wiring up keyboard navigation. The `inputRef` is attached
@ -31,17 +31,14 @@ export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEn
if (!autoFocus) {
return;
}
// Our inputs forward their ref straight to the underlying <input> DOM node, so focus + select work
// directly on it (unlike Semantic's Input, which wrapped the node behind `.inputRef`).
const node = localRef.current;
// Semantic's Input exposes the DOM <input> via `inputRef`; selecting its contents lets the user type over
// the value immediately. Other editors (e.g. Select) just take focus.
const dom = node && node.inputRef && node.inputRef.current;
if (dom) {
dom.focus();
if (dom.select) {
dom.select();
}
} else if (node && node.focus) {
if (node && node.focus) {
node.focus();
if (node.select) {
node.select();
}
}
}, [autoFocus]);
@ -81,71 +78,74 @@ export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEn
const common = {
value: value ?? '',
onChange: (e, data) => onChange(data ? data.value : e.target.value),
onChange: (e) => onChange(e.currentTarget.value),
onKeyDown: handleKeyDown,
'aria-label': field.label,
name: field.key,
};
if (field.type === 'select') {
const options = (field.options || []).map(o => ({key: o, value: o, text: o}));
const options = (field.options || []).map(o => ({value: o, label: o}));
// Allow clearing.
return <Select
fluid search clearable selection
searchable
clearable
ref={setRef}
name={field.key}
options={options}
data={options}
placeholder={field.label}
value={value ?? ''}
onChange={(e, data) => onChange(data.value)}
onChange={value => onChange(value || '')}
onKeyDown={handleKeyDown}
aria-label={field.label}
/>;
}
if (field.type === 'date') {
return <Input {...common} type='date' fluid ref={setRef}/>;
return <TextInput {...common} type='date' ref={setRef}/>;
}
if (field.type === 'number' || field.type === 'calories') {
// `calories` is a number (kcal per unit) that the Summary's ration estimate detects by type. Text (not
// number) input so arithmetic expressions can be typed; evaluated on blur/Enter.
return <Input {...common} type='text' inputMode='decimal' fluid ref={setRef} onBlur={onBlur}/>;
return <TextInput {...common} type='text' inputMode='decimal' ref={setRef} onBlur={onBlur}/>;
}
if (field.type === 'quantity') {
const unitOptions = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
return <Input
type='text'
inputMode='decimal'
fluid
ref={setRef}
name={field.key}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onBlur}
aria-label={field.label}
label={<Select
compact
name={`${field.key}_unit`}
options={unitOptions}
const unitOptions = ALL_UNITS.map(u => ({value: u, label: u}));
// Value + unit share one outline (the same layout `ActionInput` builds), but we need a real ref on the
// value input to support the parent's focus/select behavior, which a plain-function `ActionInput` cannot
// forward.
return <div className='wrolpi-action-input'>
<TextInput
type='text'
inputMode='decimal'
ref={setRef}
name={field.key}
value={value ?? ''}
onChange={e => onChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
onBlur={onBlur}
aria-label={field.label}
/>
<Select
data={unitOptions}
value={unitValue ?? field.unit ?? ''}
onChange={(e, data) => onUnitChange(data.value)}
onChange={value => onUnitChange(value)}
aria-label={`${field.label} unit`}
/>}
labelPosition='right'
/>;
style={{width: '5.5em'}}
/>
</div>;
}
if (field.type === 'location') {
// Native datalist autocomplete fed by all inventories' locations (basement, attic, ...). Using a datalist
// (rather than a Dropdown) keeps it a real text input, so the Tab/Enter row-entry flow still works.
return <Input {...common} fluid ref={setRef} list={listId}/>;
return <TextInput {...common} ref={setRef} list={listId}/>;
}
// text / fallback (supports a datalist via listId, e.g. catalog name suggestions on the Name field).
return <Input {...common} fluid ref={setRef} list={listId}/>;
return <TextInput {...common} ref={setRef} list={listId}/>;
}
// The list of selectable unit groups, re-exported for the field editor.

View file

@ -1,15 +1,12 @@
import React, {useState} from "react";
import {
Checkbox, Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow
} from "semantic-ui-react";
import {Button, Icon, Modal, Table} from "../Theme";
import {Button, Icon, IconButton, Modal, Select, Table, TextInput, Toggle} from "../ui";
import {ALL_UNITS} from "./units";
import {addCountByWeightFields, isCountByWeight} from "./computeFields";
const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories']
.map(t => ({key: t, value: t, text: t}));
.map(t => ({value: t, label: t}));
const UNIT_OPTIONS = ALL_UNITS.map(u => ({key: u, value: u, text: u}));
const UNIT_OPTIONS = ALL_UNITS.map(u => ({value: u, label: u}));
function slugifyKey(label) {
return (label || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'field';
@ -67,74 +64,72 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
return <Modal open={open} onClose={onClose} closeIcon size='large'>
<Modal.Header>Customize Fields</Modal.Header>
<Modal.Content scrolling>
{/* NOTE: do NOT use `fixed` here — semantic gives fixed-table cells `overflow:hidden`, which clips the
Type/Unit dropdown menus to the cell height. The `width` props below size the columns without it. */}
<Table celled unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell width={2}>Order</TableHeaderCell>
<TableHeaderCell width={3}>Label</TableHeaderCell>
<TableHeaderCell width={3}>Type</TableHeaderCell>
<TableHeaderCell width={5}>Unit / Options</TableHeaderCell>
<TableHeaderCell width={2} textAlign='center'
title='Show this field in the read-only portrait-mobile view'>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Order</Table.HeaderCell>
<Table.HeaderCell>Label</Table.HeaderCell>
<Table.HeaderCell>Type</Table.HeaderCell>
<Table.HeaderCell>Unit / Options</Table.HeaderCell>
<Table.HeaderCell style={{textAlign: 'center'}}
title='Show this field in the read-only portrait-mobile view'>
Mobile
</TableHeaderCell>
<TableHeaderCell width={1}/>
</TableRow>
</TableHeader>
<TableBody>
{draft.map((f, index) => <TableRow key={index}>
<TableCell collapsing>
<Button icon size='mini' onClick={() => move(index, -1)} disabled={index === 0}
aria-label='Move up'><Icon name='arrow up'/></Button>
<Button icon size='mini' onClick={() => move(index, 1)}
disabled={index === draft.length - 1}
aria-label='Move down'><Icon name='arrow down'/></Button>
</TableCell>
<TableCell>
<Input fluid value={f.label || ''} placeholder='Label'
onChange={e => update(index, {label: e.target.value})}/>
</TableCell>
<TableCell>
<Select fluid options={FIELD_TYPE_OPTIONS} value={f.type}
onChange={(e, data) => update(index, {type: data.value})}/>
</TableCell>
<TableCell>
</Table.HeaderCell>
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
{draft.map((f, index) => <Table.Row key={index}>
<Table.Cell>
<IconButton size='xs' icon='arrow up' onClick={() => move(index, -1)}
disabled={index === 0} label='Move up'/>
<IconButton size='xs' icon='arrow down' onClick={() => move(index, 1)}
disabled={index === draft.length - 1} label='Move down'/>
</Table.Cell>
<Table.Cell>
<TextInput value={f.label || ''} placeholder='Label'
onChange={e => update(index, {label: e.currentTarget.value})}/>
</Table.Cell>
<Table.Cell>
<Select data={FIELD_TYPE_OPTIONS} value={f.type}
onChange={value => update(index, {type: value})}/>
</Table.Cell>
<Table.Cell>
{f.type === 'quantity' &&
<Select fluid search options={UNIT_OPTIONS} value={f.unit || ''}
<Select searchable data={UNIT_OPTIONS} value={f.unit || ''}
placeholder='Default unit'
onChange={(e, data) => update(index, {unit: data.value})}/>}
onChange={value => update(index, {unit: value})}/>}
{f.type === 'select' &&
/* Keep the raw split (no trim/filter) so typing a comma to start the next option
isn't swallowed mid-edit; options are trimmed/cleaned on save(). */
<Input fluid placeholder='comma,separated,options'
value={(f.options || []).join(',')}
onChange={e => update(index, {options: e.target.value.split(',')})}/>}
<TextInput placeholder='comma,separated,options'
value={(f.options || []).join(',')}
onChange={e => update(index, {options: e.currentTarget.value.split(',')})}/>}
{isCountByWeight(f) &&
<span style={{opacity: 0.7, fontSize: '0.9em'}}>
<Icon name='calculator'/> Auto-counted: Total Weight ÷ Unit Weight
</span>}
</TableCell>
<TableCell collapsing textAlign='center'>
<Checkbox toggle checked={!!f.mobile} aria-label={`Show ${f.label || f.key} on mobile`}
onChange={(e, data) => update(index, {mobile: data.checked})}/>
</TableCell>
<TableCell collapsing>
<Button color='red' icon size='mini' onClick={() => remove(index)}
aria-label='Remove field'><Icon name='trash'/></Button>
</TableCell>
</TableRow>)}
</TableBody>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<Toggle checked={!!f.mobile} aria-label={`Show ${f.label || f.key} on mobile`}
onChange={e => update(index, {mobile: e.currentTarget.checked})}/>
</Table.Cell>
<Table.Cell>
<IconButton role='danger' size='xs' icon='trash' onClick={() => remove(index)}
label='Remove field'/>
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<Button onClick={add}><Icon name='plus'/>Add Field</Button>
<Button onClick={addCountByWeight} title='Add Unit Weight, Total Weight, and an auto-counted Count'>
<Icon name='balance scale'/>Count by Weight
<Button icon='plus' onClick={add}>Add Field</Button>
<Button icon='balance scale' onClick={addCountByWeight}
title='Add Unit Weight, Total Weight, and an auto-counted Count'>
Count by Weight
</Button>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={save}>Save Fields</Button>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button role='save' onClick={save}>Save Fields</Button>
</Modal.Actions>
</Modal>;
}

View file

@ -1,5 +1,5 @@
import React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {fireEvent, render, screen} from "../../test-utils";
import {FieldSchemaEditor} from "./FieldSchemaEditor";
const FIELDS = [

View file

@ -1,11 +1,8 @@
import React, {useContext, useEffect, useState} from "react";
import {toast} from "react-semantic-toasts-2";
import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Divider, Header, Icon, Loader, Modal, Table} from "../Theme";
import React, {useEffect, useState} from "react";
import {Button, Divider, Header, Icon, Loading, Modal, Table, toast} from "../ui";
import {
getInventoryBackups, postInventoryRestore, postInventoryRestorePreview, reimportInventories,
} from "../../api";
import {ThemeContext} from "../../contexts/contexts";
const formatDate = (d) => d && d.length === 8 ? `${d.substring(0, 4)}-${d.substring(4, 6)}-${d.substring(6, 8)}` : d;
@ -13,12 +10,11 @@ const itemName = (item) => (item && (item.name || `Item ${item.id}`)) || 'Item';
// Compact list of item names from a preview's add/remove arrays.
function ItemNameList({items}) {
const {t} = useContext(ThemeContext);
if (!items || items.length === 0) {
return null;
}
return <ul style={{marginTop: '0.25em'}}>
{items.map((i, idx) => <li key={`${i.id}-${idx}`} {...t}>{itemName(i)}</li>)}
{items.map((i, idx) => <li key={`${i.id}-${idx}`}>{itemName(i)}</li>)}
</ul>;
}
@ -29,7 +25,6 @@ function ItemNameList({items}) {
* `onChanged` refreshes the page's inventory state after a successful action.
*/
export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
const {t} = useContext(ThemeContext);
const [dates, setDates] = useState(null); // null while loading
const [reimporting, setReimporting] = useState(false);
const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view
@ -96,38 +91,42 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
<Modal.Header>{modeLabel} backup from {formatDate(date)}</Modal.Header>
<Modal.Content>
{applying
? <Loader active inline='centered'>Restoring…</Loader>
? <Loading>Restoring…</Loading>
: <>
{mode === 'overwrite'
? <p {...t}>
? <p>
Replace <strong>{name}</strong> entirely with the {formatDate(date)} backup
({preview.backup_count} item{preview.backup_count === 1 ? '' : 's'}).
</p>
: <p {...t}>
: <p>
Add items from the {formatDate(date)} backup that aren't already present; your
current items and fields are kept.
</p>}
{preview.add.length > 0 && <>
<Header as='h5' color='green'><Icon name='plus'/> {preview.add.length} to add</Header>
<Header as='h5' icon='plus' style={{color: 'var(--green)'}}>
{preview.add.length} to add
</Header>
<ItemNameList items={preview.add}/>
</>}
{preview.remove.length > 0 && <>
<Header as='h5' color='red'><Icon name='minus'/> {preview.remove.length} to remove</Header>
<Header as='h5' icon='minus' style={{color: 'var(--red)'}}>
{preview.remove.length} to remove
</Header>
<ItemNameList items={preview.remove}/>
</>}
{preview.fields_change &&
<p {...t}><Icon name='columns'/> The field columns will also change.</p>}
<p><Icon name='columns'/> The field columns will also change.</p>}
{preview.add.length === 0 && preview.remove.length === 0 && !preview.fields_change &&
<p {...t}>No changes — this backup matches the current inventory.</p>}
<p style={{color: 'grey'}}>{preview.unchanged} unchanged
<p>No changes — this backup matches the current inventory.</p>}
<p style={{color: 'var(--muted)'}}>{preview.unchanged} unchanged
item{preview.unchanged === 1 ? '' : 's'}</p>
</>}
</Modal.Content>
<Modal.Actions>
{!applying && <>
<Button onClick={() => setSelected(null)}>Back</Button>
<Button color='green' onClick={applyRestore}><Icon name='check'/> Apply</Button>
<Button role='cancel' onClick={() => setSelected(null)}>Back</Button>
<Button role='save' icon='check' onClick={applyRestore}>Apply</Button>
</>}
</Modal.Actions>
</Modal>;
@ -137,57 +136,58 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
<Modal.Header>Import / Restore: {name}</Modal.Header>
<Modal.Content>
<Header as='h4'>Re-import from disk</Header>
<p {...t}>
<p>
Reload every inventory from its config file. Use this after editing a file by hand, or after copying
an inventory file in from another WROLPi.
</p>
<Button primary onClick={doReimport} loading={reimporting} disabled={reimporting}>
<Icon name='sync'/> Re-import from disk
<Button role='primary' icon='sync' onClick={doReimport} loading={reimporting} disabled={reimporting}>
Re-import from disk
</Button>
<Divider/>
<Header as='h4'>Restore from a backup</Header>
<p {...t}>
<p>
<strong>Merge</strong> adds items from the backup that aren't already present.
{' '}<strong>Overwrite</strong> replaces this inventory entirely with the backup.
</p>
{dates === null
? <Loader active inline='centered'>Loading backups…</Loader>
? <Loading>Loading backups…</Loading>
: dates.length === 0
? <p {...t}>No backups yet. A backup is saved automatically whenever this inventory changes.</p>
? <p>No backups yet. A backup is saved automatically whenever this inventory changes.</p>
: <div style={{maxHeight: '320px', overflowY: 'auto'}}>
<Table compact unstackable>
<TableHeader>
<TableRow>
<TableHeaderCell>Date</TableHeaderCell>
<TableHeaderCell>Merge</TableHeaderCell>
<TableHeaderCell>Overwrite</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{dates.map(date => <TableRow key={date}>
<TableCell>{formatDate(date)}</TableCell>
<TableCell>
<Button size='mini' color='green' loading={previewing === `${date}:merge`}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Date</Table.HeaderCell>
<Table.HeaderCell>Merge</Table.HeaderCell>
<Table.HeaderCell>Overwrite</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{dates.map(date => <Table.Row key={date}>
<Table.Cell>{formatDate(date)}</Table.Cell>
<Table.Cell>
<Button size='xs' role='save' icon='plus'
loading={previewing === `${date}:merge`}
onClick={() => selectAction(date, 'merge')}>
<Icon name='plus'/> Merge
Merge
</Button>
</TableCell>
<TableCell>
<Button size='mini' color='orange'
</Table.Cell>
<Table.Cell>
<Button size='xs' color='orange' icon='refresh'
loading={previewing === `${date}:overwrite`}
onClick={() => selectAction(date, 'overwrite')}>
<Icon name='refresh'/> Overwrite
Overwrite
</Button>
</TableCell>
</TableRow>)}
</TableBody>
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
</div>}
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
<Button role='cancel' onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>;
}

View file

@ -1,7 +1,6 @@
import React from "react";
import {fireEvent, render, screen, waitFor, within} from "@testing-library/react";
import {fireEvent, render, screen, waitFor, within} from "../../test-utils";
jest.mock("react-semantic-toasts-2", () => ({toast: jest.fn()}));
jest.mock("../../api", () => ({
getInventoryBackups: jest.fn(),
postInventoryRestorePreview: jest.fn(),

View file

@ -1,6 +1,8 @@
import React, {useMemo, useState} from "react";
import {Dropdown, Input, Select} from "semantic-ui-react";
import {Button, Confirm, Header, Icon, Loader, Menu, Modal, Segment} from "../Theme";
import {
Button, Confirm, Header, IconButton, Loading, Modal, Panel, SearchBox, Select, TabBar, tabClassName, Text,
TextInput,
} from "../ui";
import {PageContainer, useTitle} from "../Common";
import {collectLocations, useCatalog, useInventories} from "../../hooks/customHooks";
import {filterItems, InventoryTable} from "./InventoryTable";
@ -13,18 +15,17 @@ import {defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey} from "./s
import {FieldSchemaEditor} from "./FieldSchemaEditor";
import {CatalogEditor} from "./CatalogEditor";
import {InventoryImportModal} from "./InventoryImportModal";
import {Media, ThemeContext} from "../../contexts/contexts";
import {Media} from "../../contexts/contexts";
const INVENTORY_TYPES = [
{key: 'food', value: 'food', text: 'Food Storage'},
{key: 'fuel', value: 'fuel', text: 'Fuel'},
{key: 'tool', value: 'tool', text: 'Tools'},
{value: 'food', label: 'Food Storage'},
{value: 'fuel', label: 'Fuel'},
{value: 'tool', label: 'Tools'},
];
function NewInventoryModal({open, onClose, onCreate}) {
const [name, setName] = useState('');
const [type, setType] = useState('food');
const {t} = React.useContext(ThemeContext);
const create = async () => {
const inventory = await onCreate(name, type);
@ -38,19 +39,19 @@ function NewInventoryModal({open, onClose, onCreate}) {
return <Modal open={open} onClose={onClose} closeIcon>
<Modal.Header>New Inventory</Modal.Header>
<Modal.Content>
<Input fluid autoFocus label='Name' value={name} onChange={e => setName(e.target.value)}
placeholder='Food Storage' style={{marginBottom: '1em'}}/>
<TextInput autoFocus label='Name' value={name} onChange={e => setName(e.currentTarget.value)}
placeholder='Food Storage' mb='1em'/>
<div>
Type:{' '}
<Select options={INVENTORY_TYPES} value={type} onChange={(e, data) => setType(data.value)}/>
<p {...t} style={{...t.style, fontSize: '0.85em', opacity: 0.7, marginTop: '0.5em'}}>
<Select data={INVENTORY_TYPES} value={type} onChange={setType}/>
<Text size='sm' c='dimmed' mt='0.5em'>
The type seeds a starting set of fields — you can customize them afterward.
</p>
</Text>
</div>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Cancel</Button>
<Button primary onClick={create} disabled={!name.trim()}>Create</Button>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button role='primary' onClick={create} disabled={!name.trim()}>Create</Button>
</Modal.Actions>
</Modal>;
}
@ -76,8 +77,6 @@ export function InventoryRoute() {
// Free-text filter applied to the active inventory's items across every column.
const [search, setSearch] = useState('');
const {t} = React.useContext(ThemeContext);
// Default to the first inventory once loaded.
React.useEffect(() => {
if (slug == null && inventories && inventories.length > 0) {
@ -134,62 +133,61 @@ export function InventoryRoute() {
};
if (inventories === null) {
return <PageContainer><Loader active inline='centered'/></PageContainer>;
return <PageContainer><Loading/></PageContainer>;
}
const inventoryOptions = inventories.map(i => ({key: i.slug, value: i.slug, text: i.name}));
const inventoryOptions = inventories.map(i => ({value: i.slug, label: i.name}));
const tabs = [
{key: 'items', label: 'Items'},
{key: 'summary', label: 'Summary'},
...(caloriesKey ? [{key: 'ration', label: 'Ration'}] : []),
{key: 'export', label: 'Export'},
];
return <PageContainer>
<Header as='h1'>Inventory</Header>
<Segment>
<Panel>
<div style={{display: 'flex', gap: '0.5em', alignItems: 'center', flexWrap: 'wrap'}}>
<Dropdown
selection
<Select
placeholder='Select an inventory'
options={inventoryOptions}
data={inventoryOptions}
value={slug || ''}
onChange={(e, data) => setSlug(data.value)}
onChange={value => setSlug(value)}
style={{minWidth: '14em'}}
/>
<Button primary icon onClick={() => setNewOpen(true)} aria-label='New inventory'>
<Icon name='plus'/>
</Button>
<Button icon onClick={() => setCatalogOpen(true)} aria-label='Food catalog'>
<Icon name='book'/> Catalog
</Button>
<IconButton role='primary' icon='plus' onClick={() => setNewOpen(true)} label='New inventory'/>
<Button icon='book' onClick={() => setCatalogOpen(true)}>Catalog</Button>
{current && <>
<Button icon onClick={() => {
<IconButton icon='edit' onClick={() => {
setRenameValue(current.name);
setRenaming(true);
}} aria-label='Rename inventory'><Icon name='edit'/></Button>
<Button icon onClick={() => setEditFieldsOpen(true)} aria-label='Customize fields'>
<Icon name='columns'/> Fields
</Button>
<Button icon onClick={() => setImportOpen(true)} aria-label='Import or restore inventory'>
<Icon name='history'/> Restore
</Button>
<Button color='red' icon onClick={() => setConfirmDelete(true)} aria-label='Delete inventory'>
<Icon name='trash'/>
</Button>
}} label='Rename inventory'/>
<Button icon='columns' onClick={() => setEditFieldsOpen(true)}>Fields</Button>
<Button icon='history' onClick={() => setImportOpen(true)}>Restore</Button>
<IconButton role='danger' icon='trash' onClick={() => setConfirmDelete(true)}
label='Delete inventory'/>
</>}
</div>
</Segment>
</Panel>
{current ? <>
<Menu pointing secondary>
<Menu.Item name='Items' active={tab === 'items'} onClick={() => setTab('items')}/>
<Menu.Item name='Summary' active={tab === 'summary'} onClick={() => setTab('summary')}/>
{caloriesKey &&
<Menu.Item name='Ration' active={tab === 'ration'} onClick={() => setTab('ration')}/>}
<Menu.Item name='Export' active={tab === 'export'} onClick={() => setTab('export')}/>
</Menu>
<TabBar>
{tabs.map(({key, label}) => <button
key={key}
type='button'
className={tabClassName(tab === key)}
onClick={() => setTab(key)}
>{label}</button>)}
</TabBar>
{tab === 'items' && <>
{/* The search filter lives here so it clearly applies to the Items tab only, not Summary/Ration/Export. */}
<Input fluid icon='search' iconPosition='left' placeholder='Search items…' value={search}
aria-label='Search items' clearable style={{marginBottom: '0.75em'}}
onChange={(e, data) => setSearch(data.value)}/>
<div style={{marginBottom: '0.75em'}}>
<SearchBox value={search} onChange={setSearch} clearable placeholder='Search items…'
label='Search items'/>
</div>
{/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */}
<Media at='mobile'>
<InventoryItemsMobile fields={fields} items={filteredItems}/>
@ -221,7 +219,7 @@ export function InventoryRoute() {
<InventoryImportModal open={importOpen} onClose={() => setImportOpen(false)}
slug={slug} name={current.name} onChanged={fetchInventories}/>
</> : <p {...t}>Create an inventory to get started.</p>}
</> : <Text>Create an inventory to get started.</Text>}
<NewInventoryModal open={newOpen} onClose={() => setNewOpen(false)} onCreate={onCreate}/>
@ -231,21 +229,23 @@ export function InventoryRoute() {
<Modal open={renaming} onClose={() => setRenaming(false)} closeIcon size='tiny'>
<Modal.Header>Rename Inventory</Modal.Header>
<Modal.Content>
<Input fluid autoFocus value={renameValue} onChange={e => setRenameValue(e.target.value)}/>
<TextInput autoFocus value={renameValue} onChange={e => setRenameValue(e.currentTarget.value)}/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setRenaming(false)}>Cancel</Button>
<Button primary onClick={doRename} disabled={!renameValue.trim()}>Save</Button>
<Button role='cancel' onClick={() => setRenaming(false)}>Cancel</Button>
<Button role='save' onClick={doRename} disabled={!renameValue.trim()}>Save</Button>
</Modal.Actions>
</Modal>
<Confirm
open={confirmDelete}
header='Delete Inventory'
content={`Delete "${current?.name}" and all its items? This cannot be undone.`}
confirmButton='Delete'
title='Delete Inventory'
destructive
confirmLabel='Delete'
onCancel={() => setConfirmDelete(false)}
onConfirm={doDelete}
/>
>
{`Delete "${current?.name}" and all its items? This cannot be undone.`}
</Confirm>
</PageContainer>;
}

View file

@ -1,6 +1,5 @@
import React, {useMemo, useRef, useState} from "react";
import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
import {Button, Icon, Table} from "../Theme";
import {Button, Checkbox, Icon, Table} from "../ui";
import {FieldCell} from "./FieldCell";
import {applyComputedCounts, computedCountConfigs} from "./computeFields";
import {NUMERIC_TYPES} from "./units";
@ -129,11 +128,11 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
const visibleItems = useMemo(() => filterItems(displayItems, fields, search), [displayItems, fields, search]);
const focusFirst = () => {
// Our inputs forward their ref straight to the underlying <input> DOM node (unlike Semantic's Input,
// which wrapped it behind `.inputRef`).
const node = firstInputRef.current;
if (node && node.focus) {
node.focus();
} else if (node && node.inputRef && node.inputRef.current) {
node.inputRef.current.focus();
}
};
@ -213,33 +212,32 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
{catalog.map(e => <option key={e.id ?? e.name} value={e.name}/>)}
</datalist>}
{selected.size > 0 &&
<Button color='red' onClick={removeSelected} style={{marginBottom: '0.5em'}}>
<Button role='danger' onClick={removeSelected} style={{marginBottom: '0.5em'}}>
Delete {selected.size} selected
</Button>}
<Table celled unstackable sortable>
<TableHeader>
<TableRow>
<TableHeaderCell collapsing/>
{sortedFields.map(f => <TableHeaderCell
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell/>
{sortedFields.map(f => <Table.HeaderCell
key={f.key}
sorted={sort && sort.key === f.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onClick={() => toggleSort(f.key)}
style={{cursor: 'pointer'}}>
onSort={() => toggleSort(f.key)}>
{f.label}
</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{visibleItems.map(item => {
const editing = edits[item.id];
const expired = isExpired(item);
return <TableRow key={item.id} negative={expired}>
<TableCell collapsing>
return <Table.Row key={item.id} failed={expired}>
<Table.Cell>
<Checkbox checked={selected.has(item.id)} onChange={() => toggleSelected(item.id)}/>
{expired && <Icon name='warning sign' color='red' title='Expired'
style={{marginLeft: '0.4em'}}/>}
</TableCell>
{sortedFields.map(f => <TableCell key={f.key}>
{expired && <Icon name='warning sign' label='Expired'
style={{color: 'var(--red)', marginLeft: '0.4em'}}/>}
</Table.Cell>
{sortedFields.map(f => <Table.Cell key={f.key}>
{editing
? <FieldCell
field={f}
@ -254,23 +252,23 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
: <span onClick={() => startEdit(item, f.key)} style={{cursor: 'pointer'}}>
{formatValue(item, f)}
</span>}
</TableCell>)}
</TableRow>;
</Table.Cell>)}
</Table.Row>;
})}
{search && visibleItems.length === 0 && (items || []).length > 0 &&
<TableRow>
<TableCell colSpan={sortedFields.length + 1} style={{opacity: 0.7}}>
<Table.Row>
<Table.Cell colSpan={sortedFields.length + 1} style={{opacity: 0.7}}>
No items match "{search}".
</TableCell>
</TableRow>}
</TableBody>
<TableFooter>
</Table.Cell>
</Table.Row>}
</Table.Body>
<Table.Footer>
{/* The persistent new-item entry row. */}
<TableRow>
<TableCell collapsing>
<Button primary size='mini' onClick={submitDraft} aria-label='Add item'>+</Button>
</TableCell>
{sortedFields.map((f, index) => <TableCell key={f.key}>
<Table.Row>
<Table.Cell>
<Button role='primary' size='xs' onClick={submitDraft} aria-label='Add item'>+</Button>
</Table.Cell>
{sortedFields.map((f, index) => <Table.Cell key={f.key}>
<FieldCell
field={f}
value={draft[f.key]}
@ -281,9 +279,9 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
inputRef={index === 0 ? firstInputRef : undefined}
listId={listIdFor(f)}
/>
</TableCell>)}
</TableRow>
</TableFooter>
</Table.Cell>)}
</Table.Row>
</Table.Footer>
</Table>
</>;
}

View file

@ -1,5 +1,5 @@
import React from "react";
import {fireEvent, render, screen, within} from "@testing-library/react";
import {fireEvent, render, screen, within} from "../../test-utils";
import {filterItems, InventoryTable} from "./InventoryTable";
const FIELDS = [
@ -252,14 +252,14 @@ describe('InventoryTable expired highlighting', () => {
{id: 1, name: 'Old', expiration_date: '2000-01-01'},
{id: 2, name: 'Fresh', expiration_date: '2999-01-01'},
];
const {container} = render(
render(
<InventoryTable slug='s' fields={FIELDS_WITH_DATE} items={items} locations={[]} onChange={jest.fn()}/>);
const rowOf = (name) => screen.getByText(name).closest('tr');
expect(rowOf('Old').className).toContain('negative');
expect(rowOf('Fresh').className).not.toContain('negative');
expect(rowOf('Old').className).toContain('wrolpi-row-failed');
expect(rowOf('Fresh').className).not.toContain('wrolpi-row-failed');
// One expired row -> one warning icon.
expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1);
expect(screen.getAllByLabelText('Expired').length).toBe(1);
});
});

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, {forwardRef} from 'react';
import {
Checkbox as MCheckbox,
MultiSelect,
@ -71,13 +71,18 @@ export interface ActionInputProps extends React.ComponentProps<typeof MTextInput
* as a single control — the input's own right border is dropped and the button
* supplies the edge.
*/
export function ActionInput({action, className, ...props}: ActionInputProps) {
if (!action) return <MTextInput className={className} {...props}/>
export const ActionInput = forwardRef<HTMLInputElement, ActionInputProps>((
{action, className, ...props}, ref
) => {
// The ref reaches the input, not the wrapper: callers use it to focus or select the
// field, and a ref to the surrounding div would do neither.
if (!action) return <MTextInput ref={ref} className={className} {...props}/>
return <div className={['wrolpi-action-input', className].filter(Boolean).join(' ')}>
<MTextInput {...props}/>
<MTextInput ref={ref} {...props}/>
{action}
</div>
}
});
ActionInput.displayName = 'ActionInput';
export {
MTextInput as TextInput,

View file

@ -21,6 +21,12 @@ export interface HeaderProps extends Omit<React.HTMLAttributes<HTMLHeadingElemen
subheader?: React.ReactNode;
/** Hairline rule underneath, separating the section from what follows. */
dividing?: boolean;
/**
* A token colour name (`green`, `red`, ...) for a heading that carries meaning —
* "3 items to add" against "2 items to remove". Call sites kept reaching for an
* inline style to do this, which is how a hex ends up in the markup.
*/
color?: string;
}
/**
@ -30,11 +36,17 @@ export interface HeaderProps extends Omit<React.HTMLAttributes<HTMLHeadingElemen
* Sizes come from ui.css, keyed on the level, rather than each call site
* choosing — that is what keeps the type scale a scale.
*/
export function Header({as = 'h3', icon, subheader, dividing, className, children, ...props}: HeaderProps) {
export function Header({
as = 'h3', icon, subheader, dividing, color, className, style, children, ...props
}: HeaderProps) {
const Tag = as;
return <div className={['wrolpi-header', dividing ? 'wrolpi-header-dividing' : '', className]
.filter(Boolean).join(' ')}>
<Tag className={`wrolpi-header-text wrolpi-header-${as}`} {...props}>
<Tag
className={`wrolpi-header-text wrolpi-header-${as}`}
style={color ? {color: `var(--${color})`, ...style} : style}
{...props}
>
{icon && (typeof icon === 'string'
? <Icon name={icon} size='medium'/>
: <Icon component={icon} size='medium'/>)}

View file

@ -58,7 +58,7 @@ import {
import {createSearchParams, useLocation, useSearchParams} from "react-router";
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
import {QueryContext, SettingsContext, StatusContext} from "../contexts/contexts";
import {toast} from "react-semantic-toasts-2";
import {toast} from "../components/ui";
import {useSearch} from "../components/Search";
import _ from "lodash";
import {TagsSelector} from "../Tags";

View file

@ -2,7 +2,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 "semantic-ui-react/dist/commonjs/collections/Message";
import {Message} from "../components/ui";
async function asyncNoOp() {
@ -341,13 +341,20 @@ export function InputForm({
const [inputProps, inputAttrs] = form.getInputProps({name, path, validator, type, required, onChange});
inputProps.disabled = disabled || inputProps.disabled;
/*
* Callers build these with Semantic's shape -- {content, header, positive|negative} --
* and the pages that build them are migrating separately, so both spellings are
* accepted until they have all moved to {kind, title}.
*/
let messageElm = null;
if (message && message.positive) {
messageElm = <Message {...message} positive/>;
} else if (message && message.negative) {
messageElm = <Message {...message}/>;
} else if (message) {
messageElm = <Message {...message}/>;
if (message) {
const kind = message.kind
|| (message.positive && 'success')
|| (message.negative && 'error')
|| 'info';
messageElm = <Message kind={kind} title={message.title ?? message.header}>
{message.children ?? message.content}
</Message>;
}
return <>

View file

@ -1,52 +1,17 @@
[
"src/App.js",
"src/DashboardPage.js",
"src/Events.js",
"src/Tags.js",
"src/api.js",
"src/components/Archive.js",
"src/components/BulkTagModal.js",
"src/components/Channels.js",
"src/components/DatesSelector.js",
"src/components/Docs.js",
"src/components/Download.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
"src/components/Files.js",
"src/components/Flasher.js",
"src/components/HelpModal.js",
"src/components/Map.js",
"src/components/MapSearchView.js",
"src/components/MapViewer.js",
"src/components/Nav.js",
"src/components/Playlists.js",
"src/components/Search.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
"src/components/VideoPlayer.js",
"src/components/Videos.js",
"src/components/Zim.js",
"src/components/admin/ControllerPage.js",
"src/components/admin/Downloads.js",
"src/components/admin/Settings.js",
"src/components/admin/Status.js",
"src/components/calculators/DriveCalculator.js",
"src/components/calculators/ElectricalCalculators.js",
"src/components/calculators/FoodStorageCalculator.js",
"src/components/calculators/HamCalculators.js",
"src/components/calculators/OneTimePadCalculator.js",
"src/components/calculators/RatioCalculator.js",
"src/components/calculators/RationCalculator.js",
"src/components/calculators/WaterCalculator.js",
"src/components/collections/BatchReorganizeModal.js",
"src/components/collections/CollectionReorganizeModal.js",
"src/components/collections/CollectionTable.js",
"src/components/collections/ConflictResolutionModal.js",
"src/components/inventory/FieldCell.js",
"src/components/inventory/FieldSchemaEditor.js",
"src/components/inventory/InventoryImportModal.js",
"src/components/inventory/InventoryRoute.js",
"src/components/inventory/InventoryTable.js",
"src/hooks/customHooks.js",
"src/hooks/useForm.js"
"src/components/admin/Downloads.js"
]