2024-11-02 15:14:02 -06:00
|
|
|
import React from "react";
|
|
|
|
|
import _ from "lodash";
|
2025-01-24 14:28:04 -07:00
|
|
|
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
|
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.
The dark-mode compatibility props went with them. `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced. Three call sites in Common.js were still
injecting the #eeeeee one into links.
Then `semantic-ui-offline/semantic.min.css` came out of App.js. That took more
with it than expected, and looking at the running app is the only reason it was
caught:
Every link lost its colour and gained the browser's underline and visited
purple. The dashboard's load averages and section headings were underlined
and purple. Links now take `--blue`, which remaps per theme, and underline
only on hover -- many of them wrap cards, posters and icons.
`box-sizing: border-box` disappeared, which silently changes the size of every
padded element in the app. Semantic's reset had been providing it.
The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
off the right edge -- the one link a user needs to reach the settings that fix
a broken WROLPi. Semantic's menu wrapped; ours does now too.
The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.
The ratchet stays rather than being deleted. At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx. It can go when the
packages are uninstalled.
Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
2026-07-30 13:41:05 -06:00
|
|
|
import {Icon, Message, Textarea} from "../components/ui";
|
2024-11-02 15:14:02 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async function asyncNoOp() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useForm({
|
|
|
|
|
fetcher,
|
|
|
|
|
submitter,
|
|
|
|
|
defaultFormData,
|
|
|
|
|
emptyFormData,
|
|
|
|
|
onSuccess = asyncNoOp,
|
|
|
|
|
onFailure = asyncNoOp,
|
|
|
|
|
onDirty = _.noop,
|
|
|
|
|
clearOnSuccess = false,
|
|
|
|
|
fetchOnSuccess = false,
|
|
|
|
|
}) {
|
|
|
|
|
const [formData, setFormData] = React.useState(defaultFormData || {});
|
|
|
|
|
const [disabled, setDisabled] = React.useState(false);
|
|
|
|
|
const [loading, setLoading] = React.useState(false);
|
|
|
|
|
const [ready, setReady] = React.useState(false);
|
|
|
|
|
const [dirty, setDirty] = React.useState(false);
|
2026-07-16 19:40:28 -06:00
|
|
|
// Form-level error (e.g. initial fetch failed). Distinct from field-level `errors`.
|
|
|
|
|
const [error, setError] = React.useState(null);
|
2024-11-02 15:14:02 -06:00
|
|
|
|
|
|
|
|
const [errors, setErrors] = React.useState({});
|
|
|
|
|
const [validators, setValidators] = React.useState({});
|
|
|
|
|
const [validValues, setValidValues] = React.useState({});
|
|
|
|
|
const [requires, setRequires] = React.useState({});
|
|
|
|
|
|
|
|
|
|
const memoizedFormData = React.useMemo(() => formData, [formData]);
|
|
|
|
|
const memoizedErrors = React.useMemo(() => errors, [errors]);
|
|
|
|
|
const memoizedValidValues = React.useMemo(() => validValues, [validValues]);
|
|
|
|
|
const memoizedRequires = React.useMemo(() => requires, [requires]);
|
|
|
|
|
|
|
|
|
|
if (clearOnSuccess && fetchOnSuccess) {
|
|
|
|
|
console.error('Cannot use both clearOnSuccess and fetchOnSuccess!');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fetchOnSuccess && !fetcher) {
|
|
|
|
|
console.error('Cannot fetchOnSuccess without fetcher!');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
// Form is dirty if it has deviated from the default.
|
|
|
|
|
setDirty(!_.isEqual(defaultFormData, memoizedFormData));
|
|
|
|
|
|
2026-07-16 19:40:28 -06:00
|
|
|
// Fetch/load failures keep the form not ready so pages can show error UI.
|
|
|
|
|
if (error) {
|
|
|
|
|
console.debug('Form invalid because of form-level error');
|
|
|
|
|
setReady(false);
|
|
|
|
|
return
|
|
|
|
|
}
|
2024-11-02 15:14:02 -06:00
|
|
|
const errorValues = Object.values(errors).filter(i => !!i);
|
|
|
|
|
if (errorValues.length > 0) {
|
|
|
|
|
console.debug('Form invalid because it has errors');
|
|
|
|
|
setReady(false);
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const invalidValues = Object.values(memoizedValidValues).filter(i => i !== true);
|
|
|
|
|
if (invalidValues.length > 0) {
|
|
|
|
|
console.debug('Form invalid because it failed validators');
|
|
|
|
|
setReady(false);
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const missingValues = Object.keys(memoizedRequires).filter(i => !_.get(formData, i));
|
|
|
|
|
if (missingValues.length > 0) {
|
|
|
|
|
console.debug(`Form invalid because it is missing required value: ${missingValues[0]}`);
|
|
|
|
|
setReady(false);
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setReady(true);
|
2026-07-16 19:40:28 -06:00
|
|
|
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires, error]);
|
2024-11-02 15:14:02 -06:00
|
|
|
|
|
|
|
|
const localFetch = async () => {
|
|
|
|
|
if (fetcher) {
|
2026-07-16 19:40:28 -06:00
|
|
|
try {
|
|
|
|
|
const result = await fetcher();
|
|
|
|
|
setFormData(result);
|
|
|
|
|
setError(null);
|
|
|
|
|
return result;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Catch so callers (useEffect, page refresh) do not get an unhandled rejection
|
|
|
|
|
// (which would show the webpack-dev-server error overlay and break Cypress).
|
|
|
|
|
console.error('Failed to fetch form data:', e);
|
|
|
|
|
setError(e?.message || String(e) || 'Failed to load');
|
|
|
|
|
}
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
localFetch();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (dirty) {
|
|
|
|
|
onDirty();
|
|
|
|
|
}
|
|
|
|
|
}, [dirty, onDirty]);
|
|
|
|
|
|
|
|
|
|
const patchFormData = (newFormData) => {
|
|
|
|
|
newFormData = {...formData, ...newFormData};
|
|
|
|
|
// console.debug('useForm.patchFormData', newFormData);
|
|
|
|
|
setFormData(newFormData);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
setFormData(emptyFormData || defaultFormData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onSubmit = async () => {
|
|
|
|
|
if (!ready) {
|
|
|
|
|
console.error('Refusing to submit form because it is not ready');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDisabled(true);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setReady(false);
|
|
|
|
|
try {
|
|
|
|
|
await submitter(formData);
|
|
|
|
|
if (fetchOnSuccess && fetcher) {
|
|
|
|
|
const result = await fetcher();
|
|
|
|
|
setFormData(result);
|
|
|
|
|
} else if (clearOnSuccess) {
|
|
|
|
|
reset();
|
|
|
|
|
}
|
|
|
|
|
await onSuccess();
|
2025-10-29 22:16:33 -06:00
|
|
|
} catch (error) {
|
|
|
|
|
await onFailure(error);
|
|
|
|
|
throw error;
|
2024-11-02 15:14:02 -06:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
setDisabled(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Using useCallback with dependencies to ensure the debounce function doesn't change on every render
|
|
|
|
|
const debouncedValidate = React.useCallback(_.debounce((path, value) => {
|
|
|
|
|
const validator = validators[path];
|
|
|
|
|
let error = null;
|
|
|
|
|
if (validator) {
|
|
|
|
|
try {
|
|
|
|
|
error = validator(value);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(`Failed to validate ${path}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.debug('useForm.debouncedValidate', path, value, 'error=', error);
|
|
|
|
|
setValidValues(prev => ({...prev, [path]: !error}));
|
|
|
|
|
setErrors(prev => {
|
|
|
|
|
if (error) {
|
|
|
|
|
return {...prev, [path]: error};
|
|
|
|
|
} else {
|
|
|
|
|
const {[path]: _, ...newErrors} = prev;
|
|
|
|
|
return newErrors;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, 300), [validators]);
|
|
|
|
|
|
|
|
|
|
const handleInputEvent = (e) => {
|
|
|
|
|
if (e) e.preventDefault();
|
|
|
|
|
let {type, value} = e.target;
|
|
|
|
|
let {path} = e.target.dataset;
|
|
|
|
|
path = path || e.target.name;
|
2026-05-31 12:53:04 -06:00
|
|
|
if (type === 'number' && value !== '' && !isNaN(value)) {
|
2026-05-31 12:43:56 -06:00
|
|
|
// Use parseFloat (not parseInt) so decimal values (sleep_requests=0.75, durations, etc.)
|
|
|
|
|
// coming from <input type="number" step="0.25"> are preserved as floats.
|
2026-05-31 12:53:04 -06:00
|
|
|
// The explicit empty-string check prevents isNaN('') === false from producing NaN in state.
|
2026-05-31 12:43:56 -06:00
|
|
|
value = parseFloat(value);
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
console.debug('handleInputEvent', 'path=', path, 'type=', type, 'value=', value);
|
|
|
|
|
|
|
|
|
|
// Change value in state first, this part happens immediately
|
|
|
|
|
const newFormData = _.set(formData, path.split('.'), value);
|
|
|
|
|
patchFormData(newFormData);
|
|
|
|
|
|
|
|
|
|
// Trigger validation after a delay
|
|
|
|
|
debouncedValidate(path, value);
|
2024-11-29 17:01:16 -07:00
|
|
|
|
|
|
|
|
return value;
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addValidator = (path, validator) => {
|
|
|
|
|
if (path && !!validator && !(path in validators)) {
|
|
|
|
|
setValidators({...validators, [path]: validator});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const addRequires = (name) => {
|
|
|
|
|
if (name && !(name in requires)) {
|
|
|
|
|
setRequires({...requires, [name]: false});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-05 12:56:24 -06:00
|
|
|
const setValue = (path, newValue) => {
|
|
|
|
|
patchFormData(_.set(formData, path, newValue));
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
const getCustomProps = ({name, validator, type = 'text', path, required = false, afterChange}) => {
|
2024-11-02 15:14:02 -06:00
|
|
|
// Props for any other elements.
|
|
|
|
|
|
|
|
|
|
path = path || name;
|
|
|
|
|
addValidator(path, validator);
|
|
|
|
|
if (required) {
|
|
|
|
|
addRequires(path);
|
|
|
|
|
}
|
|
|
|
|
const value = _.get(formData, path); // Get a path, separated by .'s
|
|
|
|
|
if (value === undefined) {
|
2025-04-17 17:02:39 -06:00
|
|
|
patchFormData(_.set(formData, path, type === 'array' ? [] : null));
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
// Allow bypass of `handleInputEvent` which only handles an input event.
|
2025-05-05 12:56:24 -06:00
|
|
|
const localSetValue = newValue => {
|
|
|
|
|
setValue(path, newValue);
|
2024-11-02 15:14:02 -06:00
|
|
|
debouncedValidate(path, newValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Props for <input/>, etc.
|
|
|
|
|
const inputProps = {
|
|
|
|
|
type,
|
|
|
|
|
disabled,
|
|
|
|
|
value,
|
2025-06-07 17:36:00 -06:00
|
|
|
onChange: (newValue) => {
|
|
|
|
|
setValue(path, newValue);
|
|
|
|
|
if (afterChange) {
|
|
|
|
|
afterChange(newValue);
|
|
|
|
|
}
|
|
|
|
|
},
|
2024-11-02 15:14:02 -06:00
|
|
|
'data-path': path,
|
|
|
|
|
}
|
|
|
|
|
// Attributes about the input, but should not be passed as properties.
|
|
|
|
|
const inputAttrs = {
|
|
|
|
|
valid: validValues[path],
|
|
|
|
|
path,
|
2025-05-05 12:56:24 -06:00
|
|
|
localSetValue,
|
2024-11-02 15:14:02 -06:00
|
|
|
};
|
|
|
|
|
return [inputProps, inputAttrs]
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
const getInputProps = ({
|
|
|
|
|
name,
|
|
|
|
|
validator,
|
|
|
|
|
path,
|
|
|
|
|
required = false,
|
|
|
|
|
type = 'text',
|
|
|
|
|
onChange = null,
|
|
|
|
|
afterChange = null
|
|
|
|
|
}) => {
|
2024-11-02 15:14:02 -06:00
|
|
|
// Props for <input/>
|
|
|
|
|
path = path || name;
|
|
|
|
|
|
|
|
|
|
// Use generic validators if no validator is provided.
|
|
|
|
|
if (!validator && type === 'url') {
|
|
|
|
|
validator = validURL;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
const [customProps, inputAttrs] = getCustomProps({name, validator, path, type, required, afterChange});
|
2024-11-02 15:14:02 -06:00
|
|
|
|
2024-11-29 17:01:16 -07:00
|
|
|
const localHandleInputEvent = async (e) => {
|
|
|
|
|
if (e) e.preventDefault();
|
|
|
|
|
const value = handleInputEvent(e);
|
|
|
|
|
if (onChange) {
|
|
|
|
|
await onChange(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
// Attributes that should be passed as properties to the input.
|
|
|
|
|
const inputProps = {
|
|
|
|
|
...customProps,
|
|
|
|
|
name: name,
|
2024-11-29 17:01:16 -07:00
|
|
|
onChange: localHandleInputEvent,
|
2024-11-02 15:14:02 -06:00
|
|
|
error: errors[path] || null,
|
|
|
|
|
required: required ? null : undefined,
|
|
|
|
|
}
|
|
|
|
|
return [inputProps, inputAttrs]
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
const getSelectionProps = ({name, validator, path, type, required = false, afterChange = null}) => {
|
2024-11-02 15:14:02 -06:00
|
|
|
// Props for Dropdowns.
|
|
|
|
|
path = path || name;
|
2025-06-07 17:36:00 -06:00
|
|
|
const [customProps, inputAttrs] = getCustomProps({name, validator, type, path, required, afterChange});
|
2024-11-02 15:14:02 -06:00
|
|
|
customProps.onChange = (e, {value}) => {
|
2025-06-07 17:36:00 -06:00
|
|
|
patchFormData(_.set(formData, path, value));
|
|
|
|
|
if (afterChange) {
|
|
|
|
|
afterChange(value);
|
|
|
|
|
}
|
2024-11-02 15:14:02 -06:00
|
|
|
};
|
|
|
|
|
return [customProps, inputAttrs]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
dirty,
|
|
|
|
|
disabled,
|
2026-07-16 19:40:28 -06:00
|
|
|
error,
|
2024-11-02 15:14:02 -06:00
|
|
|
fetcher: localFetch,
|
|
|
|
|
formData,
|
|
|
|
|
getCustomProps,
|
|
|
|
|
getInputProps,
|
|
|
|
|
getSelectionProps,
|
|
|
|
|
handleInputEvent,
|
2025-05-05 12:56:24 -06:00
|
|
|
setValue,
|
2024-11-02 15:14:02 -06:00
|
|
|
loading,
|
|
|
|
|
onSubmit,
|
|
|
|
|
ready,
|
|
|
|
|
reset,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const commaSeparatedValidator = (value) => {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
return 'Expected a string';
|
|
|
|
|
}
|
|
|
|
|
if (value.endsWith(',')) {
|
|
|
|
|
return 'Cannot end with comma';
|
|
|
|
|
}
|
|
|
|
|
if (value.startsWith(',')) {
|
|
|
|
|
return 'Cannot start with comma';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function InputForm({
|
|
|
|
|
form,
|
|
|
|
|
required = false,
|
|
|
|
|
name,
|
|
|
|
|
path,
|
|
|
|
|
type = 'text',
|
|
|
|
|
validator,
|
|
|
|
|
placeholder = '',
|
|
|
|
|
label,
|
|
|
|
|
helpContent = null,
|
|
|
|
|
helpPosition = 'top',
|
|
|
|
|
extraInputProps = {},
|
|
|
|
|
disabled = false,
|
2024-11-29 17:01:16 -07:00
|
|
|
onChange = null,
|
|
|
|
|
message = null,
|
2024-11-02 15:14:02 -06:00
|
|
|
}) {
|
2024-11-29 17:01:16 -07:00
|
|
|
const [inputProps, inputAttrs] = form.getInputProps({name, path, validator, type, required, onChange});
|
2024-11-02 15:14:02 -06:00
|
|
|
inputProps.disabled = disabled || inputProps.disabled;
|
|
|
|
|
|
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.
2026-07-30 12:21:30 -06:00
|
|
|
/*
|
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.
Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.
The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library. Its Theme.tsx check went with the
wrappers it was watching for. It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.
Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders. Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.
Two selectors naming markup that no longer exists were live bugs. The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later. And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.
"Semantic roles" survives in four places, where it means what it says.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:15:57 -06:00
|
|
|
* Callers build these as {content, header, positive|negative} --
|
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.
2026-07-30 12:21:30 -06:00
|
|
|
* and the pages that build them are migrating separately, so both spellings are
|
|
|
|
|
* accepted until they have all moved to {kind, title}.
|
|
|
|
|
*/
|
2024-11-29 17:01:16 -07:00
|
|
|
let messageElm = null;
|
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.
2026-07-30 12:21:30 -06:00
|
|
|
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>;
|
2024-11-29 17:01:16 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-05 16:59:48 -06:00
|
|
|
/*
|
|
|
|
|
* One element, not a fragment: every caller that puts an InputForm in a grid or flex
|
|
|
|
|
* container was getting the label and the input laid out as two separate items -- the
|
|
|
|
|
* input beside its own label rather than under it. The label is block with the same
|
|
|
|
|
* 4px gap the migrated fields (Download.js FieldLabel) use, so an InputForm and a
|
|
|
|
|
* DestinationForm side by side line their inputs up at the same height.
|
|
|
|
|
*/
|
|
|
|
|
return <div>
|
|
|
|
|
<label htmlFor={`${name}_input`} style={{display: 'block', marginBottom: 4}}>
|
2024-11-02 15:14:02 -06:00
|
|
|
<b>{label} {required && <RequiredAsterisk/>}</b>
|
|
|
|
|
{helpContent &&
|
2025-01-24 14:28:04 -07:00
|
|
|
<InfoPopup
|
2024-11-02 15:14:02 -06:00
|
|
|
content={helpContent}
|
|
|
|
|
position={helpPosition}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
</label>
|
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.
The dark-mode compatibility props went with them. `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced. Three call sites in Common.js were still
injecting the #eeeeee one into links.
Then `semantic-ui-offline/semantic.min.css` came out of App.js. That took more
with it than expected, and looking at the running app is the only reason it was
caught:
Every link lost its colour and gained the browser's underline and visited
purple. The dashboard's load averages and section headings were underlined
and purple. Links now take `--blue`, which remaps per theme, and underline
only on hover -- many of them wrap cards, posters and icons.
`box-sizing: border-box` disappeared, which silently changes the size of every
padded element in the app. Semantic's reset had been providing it.
The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
off the right edge -- the one link a user needs to reach the settings that fix
a broken WROLPi. Semantic's menu wrapped; ours does now too.
The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.
The ratchet stays rather than being deleted. At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx. It can go when the
packages are uninstalled.
Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
2026-07-30 13:41:05 -06:00
|
|
|
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
|
|
|
|
|
<input
|
|
|
|
|
id={`${name}_input`}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
{...extraInputProps}
|
|
|
|
|
{...inputProps}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2024-11-29 17:01:16 -07:00
|
|
|
{messageElm}
|
2026-08-05 16:59:48 -06:00
|
|
|
</div>
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function NumberInputForm({
|
|
|
|
|
form,
|
|
|
|
|
required,
|
|
|
|
|
name,
|
|
|
|
|
path,
|
|
|
|
|
validator,
|
|
|
|
|
placeholder = '',
|
|
|
|
|
label,
|
|
|
|
|
helpContent,
|
|
|
|
|
helpPosition,
|
|
|
|
|
min = 1,
|
|
|
|
|
max = 9999999999,
|
|
|
|
|
}) {
|
|
|
|
|
// Default to positive number.
|
|
|
|
|
validator = validator || ((value) => {
|
2026-05-31 12:43:56 -06:00
|
|
|
value = _.isNumber(value) ? value : parseFloat(value);
|
2024-11-02 15:14:02 -06:00
|
|
|
if (value < 0) {
|
|
|
|
|
return 'Number must be positive'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return <InputForm
|
|
|
|
|
form={form}
|
|
|
|
|
required={required}
|
|
|
|
|
name={name}
|
|
|
|
|
path={path}
|
|
|
|
|
type='number'
|
|
|
|
|
validator={validator}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
label={label}
|
|
|
|
|
helpContent={helpContent}
|
|
|
|
|
helpPosition={helpPosition}
|
|
|
|
|
extraInputProps={{min, max}}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function UrlInput({form, required = true, name = 'url', path = 'url', disabled = false}) {
|
|
|
|
|
const validator = (i) => {
|
|
|
|
|
return validURL(i) ? null : 'Invalid URL';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return <InputForm
|
|
|
|
|
form={form}
|
|
|
|
|
type='url'
|
|
|
|
|
label='URL'
|
|
|
|
|
required={required}
|
|
|
|
|
name={name}
|
|
|
|
|
path={path}
|
|
|
|
|
validator={validator}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function UrlsTextarea({name = 'urls', required, form}) {
|
|
|
|
|
required = required !== undefined;
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
// Memoize input props to prevent recomputation during render
|
|
|
|
|
const [inputProps, inputAttrs] = React.useMemo(() => {
|
2024-11-02 15:14:02 -06:00
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
const validator = (value) => {
|
|
|
|
|
if (!validURLs(value)) {
|
|
|
|
|
return 'Invalid URLs';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return form.getInputProps({name, validator, required});
|
|
|
|
|
}, [form, name, required]);
|
2024-11-02 15:14:02 -06:00
|
|
|
|
|
|
|
|
const handleDrop = (e) => {
|
|
|
|
|
if (e) e.preventDefault();
|
|
|
|
|
const droppedUrl = e.dataTransfer.getData('text');
|
|
|
|
|
let urls = (inputProps.value || '').split('\n');
|
|
|
|
|
urls = [...urls, droppedUrl];
|
|
|
|
|
urls = urls.filter(i => !!i).join('\n');
|
2026-05-02 13:35:08 -06:00
|
|
|
inputAttrs.localSetValue(`${urls}\n`);
|
2024-11-02 15:14:02 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (event) => {
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
2025-06-07 17:36:00 -06:00
|
|
|
event.preventDefault();
|
|
|
|
|
form.onSubmit();
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
return (
|
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.
The dark-mode compatibility props went with them. `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced. Three call sites in Common.js were still
injecting the #eeeeee one into links.
Then `semantic-ui-offline/semantic.min.css` came out of App.js. That took more
with it than expected, and looking at the running app is the only reason it was
caught:
Every link lost its colour and gained the browser's underline and visited
purple. The dashboard's load averages and section headings were underlined
and purple. Links now take `--blue`, which remaps per theme, and underline
only on hover -- many of them wrap cards, posters and icons.
`box-sizing: border-box` disappeared, which silently changes the size of every
padded element in the app. Semantic's reset had been providing it.
The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
off the right edge -- the one link a user needs to reach the settings that fix
a broken WROLPi. Semantic's menu wrapped; ours does now too.
The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.
The ratchet stays rather than being deleted. At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx. It can go when the
packages are uninstalled.
Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
2026-07-30 13:41:05 -06:00
|
|
|
<Textarea
|
|
|
|
|
id="urls_textarea"
|
|
|
|
|
label="URLs"
|
|
|
|
|
required
|
|
|
|
|
error={inputProps.error}
|
|
|
|
|
placeholder="Enter one URL per line"
|
|
|
|
|
name="urls"
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
autosize
|
|
|
|
|
minRows={3}
|
|
|
|
|
{...inputProps}
|
|
|
|
|
/>
|
2025-06-07 17:36:00 -06:00
|
|
|
);
|
2024-11-02 15:14:02 -06:00
|
|
|
}
|
|
|
|
|
|
2025-06-07 17:36:00 -06:00
|
|
|
export function ToggleForm({form, name, label, path, icon = null, iconSize = 'big', disabled = false}) {
|
2024-11-02 15:14:02 -06:00
|
|
|
const [inputProps, inputAttrs] = form.getCustomProps({name, path})
|
|
|
|
|
|
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.
The dark-mode compatibility props went with them. `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced. Three call sites in Common.js were still
injecting the #eeeeee one into links.
Then `semantic-ui-offline/semantic.min.css` came out of App.js. That took more
with it than expected, and looking at the running app is the only reason it was
caught:
Every link lost its colour and gained the browser's underline and visited
purple. The dashboard's load averages and section headings were underlined
and purple. Links now take `--blue`, which remaps per theme, and underline
only on hover -- many of them wrap cards, posters and icons.
`box-sizing: border-box` disappeared, which silently changes the size of every
padded element in the app. Semantic's reset had been providing it.
The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
off the right edge -- the one link a user needs to reach the settings that fix
a broken WROLPi. Semantic's menu wrapped; ours does now too.
The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.
The ratchet stays rather than being deleted. At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx. It can go when the
packages are uninstalled.
Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
2026-07-30 13:41:05 -06:00
|
|
|
const iconElm = icon ? <Icon size={iconSize === 'big' ? 'large' : iconSize} name={icon}/> : null;
|
|
|
|
|
return <div className='wrolpi-form-field'>
|
|
|
|
|
<label><b>{label}</b></label>
|
|
|
|
|
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
|
|
|
|
|
{iconElm}
|
|
|
|
|
<Toggle
|
|
|
|
|
disabled={disabled || form.disabled}
|
|
|
|
|
name={name}
|
|
|
|
|
checked={inputProps.value}
|
|
|
|
|
onChange={inputProps.onChange}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-05-05 12:56:24 -06:00
|
|
|
}
|