mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
87c9bff8ed
commit
e8dd804010
48 changed files with 3273 additions and 3877 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
235
app/src/Tags.js
235
app/src/Tags.js
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)}/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'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 "Use Current Browser" 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
})}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
</>}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'/>)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 <>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue