mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
283 occurrences across 23 files, all of them mine and all of them in the theme/UI work: comments, test names, and a handful of local identifiers (`colours`, `roleColours`, `awaitColoured`) plus one test fixture tag named 'Uncoloured'. Nothing was required to keep the other spelling. Checked before replacing: no CSS custom property or class name contains it, no library prop or API key does, and every string holding it is a test name or our own copy. The renamed identifiers were checked for collisions with an existing `color`/`colors` in the same file first -- there were none. Verified as a pure rename: 262 lines changed, and normalising the spelling on every removed line reproduces the added line exactly, for all 262. So the diff contains no other edit hiding in it. 1134 jest / 64 suites, 271 cypress component tests with the same 12 pre-existing Semantic-era failures in Tags/Channels/Download/Inventory, clean npm run build.
2288 lines
79 KiB
JavaScript
2288 lines
79 KiB
JavaScript
import React, {useContext, useEffect, useState} from "react";
|
|
import {Link, NavLink, useNavigate} from "react-router";
|
|
import {
|
|
Accordion,
|
|
Breadcrumbs as UIBreadcrumbs,
|
|
Button,
|
|
ButtonGroup,
|
|
CardGroup,
|
|
Confirm,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
IconStack,
|
|
Label,
|
|
Loading,
|
|
Menu,
|
|
Message,
|
|
Modal,
|
|
Pagination,
|
|
Panel,
|
|
SearchBox,
|
|
Statistic,
|
|
TabBar,
|
|
tabClassName,
|
|
TextInput,
|
|
Toggle as UIToggle,
|
|
Tooltip,
|
|
} from "./ui";
|
|
import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useVnc, useWROLMode} from "../hooks/customHooks";
|
|
import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts";
|
|
import {themeChoices} from "../themes/names";
|
|
import {contrastRatio as measureContrast} from "../themes/contrast";
|
|
import {FilePreviewContext} from "./FilePreview";
|
|
import _ from "lodash";
|
|
import {killDownloads, startDownloads, unlockCookies} from "../api";
|
|
import {allFrequencyOptions, HELP_VIEWER_URI, NAME, validUrlRegex} from "./Vars";
|
|
|
|
export function Paginator({activePage, onPageChange, totalPages, showFirstAndLast, size = 'mini'}) {
|
|
// Fewer page numbers on a phone: the strip has to fit without wrapping.
|
|
const pager = (siblingRange) => <Pagination
|
|
activePage={activePage}
|
|
totalPages={totalPages}
|
|
onPageChange={onPageChange}
|
|
siblingRange={siblingRange}
|
|
showFirstAndLast={showFirstAndLast}
|
|
/>;
|
|
|
|
return <>
|
|
<Media at='mobile'>{pager(1)}</Media>
|
|
<Media greaterThanOrEqual='tablet'>{pager(3)}</Media>
|
|
</>
|
|
}
|
|
|
|
export function divmod(x, y) {
|
|
return [Math.floor(x / y), x % y];
|
|
}
|
|
|
|
export function secondsToHumanElapsed(seconds, short = true) {
|
|
// Convert the provided seconds into a human-readable string of the time elapsed between the provided timestamp
|
|
// and now.
|
|
if (!seconds || seconds < 0) {
|
|
return null;
|
|
}
|
|
|
|
// Get seconds elapsed between now and `seconds` which is a UTC epoch.
|
|
const localNow = (new Date()).getTime() / 1000;
|
|
|
|
let years;
|
|
let days;
|
|
let hours;
|
|
let minutes;
|
|
|
|
seconds = Math.abs(localNow - seconds);
|
|
[years, seconds] = divmod(seconds, secondsToYears);
|
|
[days, seconds] = divmod(seconds, secondsToDays);
|
|
[hours, seconds] = divmod(seconds, secondsToHours);
|
|
[minutes, seconds] = divmod(seconds, secondsToMinutes);
|
|
seconds = Math.floor(seconds);
|
|
|
|
if (short) {
|
|
if (years > 0 && days > 30) {
|
|
return `${years}y${days}d`;
|
|
} else if (years > 0) {
|
|
return `${years}y`;
|
|
} else if (days > 0) {
|
|
return `${days}d`;
|
|
} else if (hours > 0) {
|
|
return `${hours}h`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m`;
|
|
}
|
|
return `${seconds}s`
|
|
} else {
|
|
if (years > 0 && days > 30) {
|
|
return `${years} years ${days} days`;
|
|
} else if (years > 0) {
|
|
return `${years} years`;
|
|
} else if (days > 0) {
|
|
return `${days} days`;
|
|
} else if (hours > 0) {
|
|
return `${hours} hours`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes} minutes`;
|
|
}
|
|
return `${seconds} seconds`
|
|
}
|
|
}
|
|
|
|
export function secondsToElapsedPopup(seconds) {
|
|
// Return a Popup which allows the user see a more detailed timestamp when hovering.
|
|
const elapsed = secondsToHumanElapsed(seconds);
|
|
if (!elapsed) {
|
|
return <></>;
|
|
}
|
|
return <Tooltip label={secondsToTimestamp(seconds)}>
|
|
<span>{elapsed}</span>
|
|
</Tooltip>
|
|
}
|
|
|
|
export function isoDatetimeToElapsedPopup(dt) {
|
|
let d = new Date(dt);
|
|
return secondsToElapsedPopup(d.getTime() / 1000);
|
|
}
|
|
|
|
export function isoDatetimeToAgoPopup(dt, short = true) {
|
|
const seconds = (new Date(dt)).getTime() / 1000;
|
|
// Return a Popup which allows the user see a more detailed timestamp when hovering.
|
|
const elapsed = secondsToHumanElapsed(seconds, short);
|
|
if (seconds === 0 || !elapsed) {
|
|
return <></>;
|
|
}
|
|
return <Tooltip label={`${elapsed} ago`}>
|
|
<span>{isoDatetimeToString(dt)}</span>
|
|
</Tooltip>
|
|
}
|
|
|
|
export function secondsToHMS(totalSeconds) {
|
|
let hours = Math.floor(totalSeconds / 3600);
|
|
totalSeconds -= hours * 3600;
|
|
let minutes = Math.floor(totalSeconds / 60);
|
|
let seconds = totalSeconds - (minutes * 60);
|
|
|
|
hours = String('00' + hours).slice(-2);
|
|
minutes = String('00' + minutes).slice(-2);
|
|
seconds = String('00' + seconds).slice(-2);
|
|
|
|
return {hours, minutes, seconds};
|
|
}
|
|
|
|
export function Duration({totalSeconds}) {
|
|
const {hours, minutes, seconds} = secondsToHMS(totalSeconds);
|
|
|
|
if (hours > 0) {
|
|
return <div className='duration-overlay'>{hours}:{minutes}:{seconds}</div>
|
|
} else if (totalSeconds) {
|
|
return <div className='duration-overlay'>{minutes}:{seconds}</div>
|
|
} else {
|
|
return <></>
|
|
}
|
|
}
|
|
|
|
export function isoDatetimeToString(dt, time = false) {
|
|
// Convert a datetime to a human-readable date format.
|
|
let d = <React.Fragment/>;
|
|
if (dt && time) {
|
|
d = new Date(dt);
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
d = `${d.toDateString()} ${hours}:${minutes}:${seconds}`;
|
|
} else if (dt) {
|
|
d = new Date(dt);
|
|
d = d.toDateString();
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/*
|
|
* Both card links COMPOSE the caller's className rather than being overwritten by it.
|
|
* Written as `className='...' {...props}`, the spread put the caller's class last and it
|
|
* replaced the pair outright -- so every archive title, which passes
|
|
* `card-title-ellipsis`, lost `card-link` and rendered in the link color instead of the
|
|
* card's text color. The one card type whose classes are set this way, and the one whose
|
|
* title looked wrong.
|
|
*/
|
|
const cardLinkClass = (className) =>
|
|
['no-link-underscore', 'card-link', className].filter(Boolean).join(' ');
|
|
|
|
export function CardLink({to, newTab = false, className, ...props}) {
|
|
props = newTab === true ? {...props, target: '_blank', rel: 'noopener noreferrer'} : props;
|
|
return <Link to={to} className={cardLinkClass(className)} {...props}>
|
|
{props.children}
|
|
</Link>
|
|
}
|
|
|
|
export function ExternalCardLink({to, children, className, ...props}) {
|
|
return <a
|
|
href={to}
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className={cardLinkClass(className)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</a>
|
|
}
|
|
|
|
export function PreviewLink({file, children, className, ...props}) {
|
|
const {setPreviewFile} = React.useContext(FilePreviewContext);
|
|
className = className ? `clickable ${className}` : `clickable `;
|
|
return <span className={className} onClick={() => setPreviewFile(file)} {...props}>
|
|
{children}
|
|
</span>
|
|
}
|
|
|
|
export function PreviewPath({path, mimetype, taggable = true, ...props}) {
|
|
return <PreviewLink file={{path, mimetype, taggable}} {...props}/>
|
|
}
|
|
|
|
export function DirectoryLink({path}) {
|
|
if (!path) return <span>Unknown</span>;
|
|
const href = `/media/${encodeMediaPath(path)}/`;
|
|
return <a href={href} target='_blank' rel='noopener noreferrer'
|
|
style={{color: 'inherit', textDecoration: 'none'}}>
|
|
{path}
|
|
</a>
|
|
}
|
|
|
|
export function RequiredAsterisk() {
|
|
return <span style={{color: '#db2828'}}> *</span>
|
|
}
|
|
|
|
/**
|
|
* Format a frequency value (in seconds) to a human-readable string using allFrequencyOptions.
|
|
* @param {number|null} frequency - Frequency in seconds
|
|
* @returns {string} Human-readable frequency text or '-' if null/undefined
|
|
*/
|
|
export function formatFrequency(frequency) {
|
|
if (frequency === null || frequency === undefined) {
|
|
return '-';
|
|
}
|
|
const option = allFrequencyOptions[frequency];
|
|
return option ? option.text : `${frequency}s`;
|
|
}
|
|
|
|
const secondsToYears = 31536000;
|
|
const secondsToDays = 86400;
|
|
const secondsToHours = 3600;
|
|
const secondsToMinutes = 60;
|
|
|
|
export function secondsToFullDuration(seconds) {
|
|
let duration = '';
|
|
|
|
let years, days, hours, minutes;
|
|
[years, seconds] = divmod(seconds, secondsToYears);
|
|
[days, seconds] = divmod(seconds, secondsToDays);
|
|
[hours, seconds] = divmod(seconds, secondsToHours);
|
|
[minutes] = divmod(seconds, secondsToMinutes);
|
|
if (years > 0) {
|
|
duration = `${years}Y`;
|
|
}
|
|
if (days > 0) {
|
|
duration = `${duration} ${days}D`;
|
|
}
|
|
if (hours > 0 || minutes > 0) {
|
|
hours = String(hours).padStart(2, '0');
|
|
minutes = String(minutes).padStart(2, '0');
|
|
duration = `${duration} ${hours}:${minutes}`;
|
|
}
|
|
if (years === 0 && days === 0 && hours === 0 && minutes === 0) {
|
|
// Less than a minute.
|
|
seconds = String(seconds).padStart(2, '0');
|
|
duration = `00:00:${seconds}`;
|
|
}
|
|
return duration.trim();
|
|
}
|
|
|
|
export function secondsToTimestamp(seconds) {
|
|
let d = new Date(seconds * 1000);
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
const sec = String(d.getSeconds()).padStart(2, '0');
|
|
return `${d.getFullYear()}-${month}-${day} ${hours}:${minutes}:${sec}`;
|
|
}
|
|
|
|
export function humanFileSize(bytes, dp = 1) {
|
|
// Handle null/undefined
|
|
if (bytes == null) {
|
|
return '-';
|
|
}
|
|
|
|
// Convert string to number if needed
|
|
if (typeof bytes === 'string') {
|
|
bytes = parseFloat(bytes);
|
|
}
|
|
|
|
// Handle invalid numbers
|
|
if (isNaN(bytes) || !isFinite(bytes)) {
|
|
return '-';
|
|
}
|
|
|
|
const thresh = 1024;
|
|
|
|
if (Math.abs(bytes) < thresh) {
|
|
return bytes + ' B';
|
|
}
|
|
|
|
const units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
let u = -1;
|
|
const r = 10 ** dp;
|
|
|
|
do {
|
|
bytes /= thresh;
|
|
++u;
|
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
|
|
|
return bytes.toFixed(dp) + ' ' + units[u];
|
|
}
|
|
|
|
export function humanBandwidth(bytes) {
|
|
// Convert bytes to MBps and return a string.
|
|
const thresh = 1024;
|
|
|
|
if (Math.abs(bytes) < thresh) {
|
|
return bytes + ' Bps';
|
|
}
|
|
|
|
const units = ['KBps', 'MBps', 'GBps', 'TBps', 'PBps', 'EBps', 'ZBps', 'YBps'];
|
|
let u = -1;
|
|
const r = 10;
|
|
|
|
do {
|
|
bytes /= thresh;
|
|
++u;
|
|
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
|
|
|
return bytes.toFixed(0) + ' ' + units[u];
|
|
}
|
|
|
|
export function humanNumber(num, dp = 1) {
|
|
// Convert large numbers to a more human-readable format.
|
|
// >> humanNumber(1000)
|
|
// 1.0k
|
|
// >> humanNumber(1500000)
|
|
// 1.5m
|
|
const divisor = 1000;
|
|
if (Math.abs(num) < divisor) {
|
|
return num;
|
|
}
|
|
const units = ['k', 'm', 'b'];
|
|
let i = -1;
|
|
const r = 10 ** dp;
|
|
do {
|
|
num /= divisor;
|
|
++i;
|
|
} while (Math.round(Math.abs(num) * r) / r >= divisor && i < units.length - 1);
|
|
|
|
return num.toFixed(dp) + units[i];
|
|
}
|
|
|
|
export function replaceNullValues(obj, newValue) {
|
|
newValue = newValue === undefined ? '' : newValue;
|
|
let keys = Object.keys(obj);
|
|
for (let i = 0; i < keys.length; i++) {
|
|
let key = keys[i];
|
|
obj[key] = obj[key] === null ? newValue : obj[key];
|
|
}
|
|
}
|
|
|
|
export function enumerate(array) {
|
|
let newArray = [];
|
|
for (let i = 0; i < array.length; i++) {
|
|
newArray = newArray.concat([[i, array[i]]]);
|
|
}
|
|
return newArray;
|
|
}
|
|
|
|
export function arraysEqual(a, b) {
|
|
if (a === b) return true;
|
|
if (a == null || b == null) return false;
|
|
if (a.length !== b.length) return false;
|
|
|
|
for (var i = 0; i < a.length; ++i) {
|
|
if (a[i] !== b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function scrollToTop() {
|
|
window.scrollTo({
|
|
top: 0, behavior: "auto"
|
|
});
|
|
}
|
|
|
|
export function scrollToTopOfElement(element, smooth = true) {
|
|
element.scroll({
|
|
top: 0,
|
|
behavior: smooth ? 'smooth' : 'auto',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Shared behaviour for the search inputs: keep a local value so typing is not
|
|
* throttled by the parent, but follow `searchStr` when it changes underneath.
|
|
*/
|
|
const useSearchValue = (searchStr, onChange) => {
|
|
const [value, setValue] = useState(searchStr || '');
|
|
|
|
React.useEffect(() => {
|
|
// `searchStr` is the source of truth from the parent (often an async URL query
|
|
// param). Depend ONLY on `searchStr` -- never on `value` -- otherwise a transient
|
|
// render where the local `value` has advanced ("ex") but `searchStr` still trails
|
|
// ("e") fires this effect and reverts `value`, garbling fast typing.
|
|
if ((searchStr || '') !== value) {
|
|
setValue(searchStr || '');
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchStr]);
|
|
|
|
const handleChange = (newValue) => {
|
|
setValue(newValue);
|
|
if (onChange) {
|
|
// Try to call the remote function, don't let its failure break this.
|
|
try {
|
|
onChange(newValue);
|
|
} catch (e) {
|
|
console.error('Call to onChange failed', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {value, setValue, handleChange};
|
|
}
|
|
|
|
export function SearchInput({
|
|
searchStr,
|
|
onSubmit,
|
|
onChange,
|
|
onClear,
|
|
placeholder = 'Search...',
|
|
inputRef = null,
|
|
...props
|
|
}) {
|
|
const {value, setValue, handleChange} = useSearchValue(searchStr, onChange);
|
|
|
|
const handleClear = () => {
|
|
setValue('');
|
|
if (onClear) {
|
|
onClear();
|
|
}
|
|
}
|
|
|
|
return <div className='search-container' {...props}>
|
|
<SearchBox
|
|
value={value}
|
|
onChange={handleChange}
|
|
onSubmit={onSubmit}
|
|
onClear={handleClear}
|
|
placeholder={placeholder}
|
|
inputRef={inputRef}
|
|
clearable
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
export function SearchResultsInput({
|
|
searchStr,
|
|
onSubmit,
|
|
onClear = null,
|
|
onChange = null,
|
|
placeholder = 'Search...',
|
|
clearable = false,
|
|
clearDisabled = null,
|
|
results = undefined,
|
|
handleResultSelect = null,
|
|
resultRenderer = undefined,
|
|
loading = false,
|
|
inputRef = null,
|
|
autoFocus = false,
|
|
...props
|
|
}) {
|
|
const {value, setValue, handleChange} = useSearchValue(searchStr, onChange);
|
|
|
|
const handleClear = () => {
|
|
setValue('');
|
|
if (onClear) {
|
|
onClear();
|
|
}
|
|
}
|
|
|
|
const localHandleResultSelect = (result) => {
|
|
if (handleResultSelect) {
|
|
// Semantic handed its callers `{result}`, and they destructure it -- passing the
|
|
// bare result silently broke navigation from the search suggestions. The shape
|
|
// stays until the call sites are ready to change together.
|
|
handleResultSelect({result});
|
|
} else {
|
|
console.error('No handleResultSelect defined!');
|
|
}
|
|
}
|
|
|
|
return <div className='search-container' {...props}>
|
|
<SearchBox
|
|
value={value}
|
|
onChange={handleChange}
|
|
onSubmit={onSubmit}
|
|
onResultSelect={localHandleResultSelect}
|
|
onClear={handleClear}
|
|
results={results}
|
|
resultRenderer={resultRenderer}
|
|
loading={loading}
|
|
placeholder={placeholder}
|
|
clearable={clearable}
|
|
clearDisabled={clearDisabled}
|
|
inputRef={inputRef}
|
|
autoFocus={autoFocus}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
export function WROLModeMessage({content}) {
|
|
const wrolModeEnabled = useWROLMode();
|
|
|
|
if (wrolModeEnabled) {
|
|
return <Message kind='warning' icon='lock' title='WROL Mode Enabled'>{content}</Message>
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function DownloadWindowMessage() {
|
|
const {status} = React.useContext(StatusContext);
|
|
const downloads = status ? status.downloads : null;
|
|
|
|
if (downloads && downloads.outside_download_window) {
|
|
return <Message kind='info' icon='history' title='Outside Download Window'>
|
|
Downloads are paused because the current time is outside the configured download window.
|
|
</Message>
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function DailyLimitMessage() {
|
|
const {status} = React.useContext(StatusContext);
|
|
const downloads = status ? status.downloads : null;
|
|
|
|
if (downloads && downloads.daily_limit_reached && !downloads.disabled && !downloads.stopped) {
|
|
return <Message kind='info' icon='stop' title='Max Daily Downloads Reached'>
|
|
Downloads are paused because the daily download limit has been reached. Downloads will
|
|
resume tomorrow.
|
|
</Message>
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function CookiesUnlockModal({open, onClose, onSuccess}) {
|
|
const [password, setPassword] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const passwordRef = React.useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const timer = setTimeout(() => {
|
|
if (passwordRef.current) {
|
|
passwordRef.current.focus();
|
|
}
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [open]);
|
|
|
|
const handleUnlock = async () => {
|
|
setSubmitting(true);
|
|
const result = await unlockCookies(password);
|
|
setSubmitting(false);
|
|
if (result.success) {
|
|
setPassword('');
|
|
if (onSuccess) onSuccess();
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return <Modal open={open} onClose={onClose} size='tiny'>
|
|
<Modal.Header>Unlock Cookies</Modal.Header>
|
|
<Modal.Content>
|
|
<TextInput
|
|
ref={passwordRef}
|
|
label='Password'
|
|
type='password'
|
|
placeholder='Enter your encryption password'
|
|
value={password}
|
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleUnlock()}
|
|
/>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={onClose}>Cancel</Button>
|
|
<Button
|
|
role='save'
|
|
icon='unlock'
|
|
onClick={handleUnlock}
|
|
loading={submitting}
|
|
disabled={!password || submitting}
|
|
>
|
|
Unlock
|
|
</Button>
|
|
</Modal.Actions>
|
|
</Modal>;
|
|
}
|
|
|
|
export function CookiesLockedMessage() {
|
|
const {settings} = React.useContext(SettingsContext);
|
|
const {status} = React.useContext(StatusContext);
|
|
const flags = status?.flags || {};
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
if (!flags.cookies_exist || flags.cookies_unlocked || settings?.wrol_mode) {
|
|
return null;
|
|
}
|
|
|
|
return <>
|
|
<Message kind='warning' icon='lock' title='Cookies Locked'>
|
|
You have encrypted cookies stored, but they are currently locked.
|
|
<Button role='cancel' icon='unlock' size='xs' style={{marginLeft: '0.7em'}}
|
|
onClick={() => setModalOpen(true)}>
|
|
Unlock Cookies
|
|
</Button>
|
|
</Message>
|
|
|
|
<CookiesUnlockModal open={modalOpen} onClose={() => setModalOpen(false)}/>
|
|
</>;
|
|
}
|
|
|
|
// Thanks https://www.npmjs.com/package/text-ellipsis
|
|
export function textEllipsis(str, maxLength = 100, {side = "end", ellipsis = "..."} = {}) {
|
|
if (str && str.length > maxLength) {
|
|
switch (side) {
|
|
case "start":
|
|
return ellipsis + str.slice(-(maxLength - ellipsis.length));
|
|
case "end":
|
|
default:
|
|
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
|
|
}
|
|
}
|
|
return str;
|
|
}
|
|
|
|
export function TabLinks({links, right}) {
|
|
return <TabBar right={right}>
|
|
{links.map((link) => <NavLink
|
|
to={link.to}
|
|
key={link.to}
|
|
end={link.end === true ? true : null}
|
|
className={({isActive}) => tabClassName(link.isActive ? link.isActive() : isActive)}
|
|
>
|
|
{link.text}
|
|
</NavLink>)}
|
|
</TabBar>
|
|
}
|
|
|
|
export function PageContainer(props) {
|
|
return <>
|
|
<Media at='mobile'>
|
|
<div style={{marginTop: '1em', padding: 0}}>{props.children}</div>
|
|
</Media>
|
|
<Media greaterThanOrEqual='tablet'>
|
|
<div style={{marginTop: '1em', padding: '1em'}}>{props.children}</div>
|
|
</Media>
|
|
</>;
|
|
}
|
|
|
|
export function CardGroupCentered(props) {
|
|
// One responsive grid; it centres what it cannot fill, so the two viewports no
|
|
// longer need separate markup.
|
|
return <div style={{marginTop: '1em'}}>
|
|
<CardGroup>{props.children}</CardGroup>
|
|
</div>
|
|
}
|
|
|
|
/**
|
|
* Resolve a filename-only path to a full relative path using the file's directory.
|
|
* Paths in FileGroup.data are stored as filenames only for performance (fast moves/renames).
|
|
* This function resolves them to full relative paths suitable for /media/ URLs.
|
|
*
|
|
* @param {string} path - The path to resolve (may be filename-only or already a full path)
|
|
* @param {string} directory - The directory to prepend if path is filename-only
|
|
* @returns {string|null} - The resolved path, or null if path is falsy
|
|
*/
|
|
export function resolveDataPath(path, directory) {
|
|
if (!path) return null;
|
|
if (path.includes('/')) return path; // Already a full relative path
|
|
return directory ? `${directory}/${path}` : path;
|
|
}
|
|
|
|
export function findPosterPath(file) {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const {files, poster_path, cover_path, screenshot_path, video, directory} = file;
|
|
|
|
if (poster_path) {
|
|
return poster_path;
|
|
}
|
|
if (cover_path) {
|
|
return cover_path;
|
|
}
|
|
if (screenshot_path) {
|
|
return screenshot_path;
|
|
}
|
|
// video.poster_path is already a full relative path (from Video.__json__)
|
|
if (video && video['poster_path']) {
|
|
return video['poster_path'];
|
|
}
|
|
// data paths are filename-only, need to resolve with directory
|
|
if (file['data'] && file['data']['cover_path']) {
|
|
// Ebook.
|
|
return resolveDataPath(file['data']['cover_path'], directory);
|
|
}
|
|
if (file['data'] && file['data']['poster_path']) {
|
|
// PDF or Video (cached).
|
|
return resolveDataPath(file['data']['poster_path'], directory);
|
|
}
|
|
if (file['data'] && file['data']['screenshot_path']) {
|
|
// Archive.
|
|
return resolveDataPath(file['data']['screenshot_path'], directory);
|
|
}
|
|
// files[].path is already resolved by my_files() on the backend
|
|
if (!_.isEmpty(files)) {
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
if (file['mimetype'] && file['mimetype'].startsWith('image/')) {
|
|
// Found an image file, use that as the poster.
|
|
return file['path'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function CardPoster({to, file, overlay}) {
|
|
const navigate = useNavigate();
|
|
|
|
// Marks a tagged file, pinned to the poster's corner.
|
|
const cardTagIcon = <div className='wrolpi-card-tag'><Icon name='tag' size={14} label='Tagged'/></div>;
|
|
let imageLabel = !_.isEmpty(file.tags) ? cardTagIcon : null;
|
|
|
|
let posterPath = findPosterPath(file);
|
|
|
|
if (posterPath) {
|
|
// FileGroup has a poster (screenshot/thumbnail) file.
|
|
posterPath = `/media/${encodeMediaPath(posterPath)}`;
|
|
|
|
/*
|
|
* Sized by `.wrolpi-card-poster`: as wide as the card allows, capped in height,
|
|
* natural ratio. The corner chrome goes INSIDE the link, beside the image:
|
|
*
|
|
* - the frame shrink-wraps the painted image, so a mark pinned to its corner
|
|
* tracks the poster rather than the letterbox space beside it. A height-capped
|
|
* poster is a good deal narrower than its band -- 155px inside a 233px card for
|
|
* a book cover, less for a vertical video;
|
|
* - and the mark stays part of the poster's hit target, rather than a dead spot
|
|
* on the corner of an otherwise clickable image.
|
|
*/
|
|
const inner = <>
|
|
{imageLabel}
|
|
<img alt='poster' src={posterPath}/>
|
|
{overlay}
|
|
</>;
|
|
|
|
return <div className='wrolpi-card-poster'>
|
|
{to
|
|
// Link within this App.
|
|
? <Link to={to} className='wrolpi-card-poster-frame'>{inner}</Link>
|
|
// Preview the file.
|
|
: <PreviewLink file={file} className='wrolpi-card-poster-frame'>{inner}</PreviewLink>}
|
|
</div>
|
|
} else {
|
|
// FileGroup has no poster.
|
|
if (!to || (to.startsWith('/media/') || to.startsWith('/download/'))) {
|
|
// "to" is a downloadable file outside the app, preview the file.
|
|
return <PreviewLink file={file}>
|
|
<div className='wrolpi-card-icon'>
|
|
{imageLabel}
|
|
<FileIcon file={file}/>
|
|
</div>
|
|
</PreviewLink>
|
|
} else if (!posterPath && to) {
|
|
// Link to the full page in this App.
|
|
return <Link to={to}>
|
|
<div className='wrolpi-card-icon' onClick={() => navigate(to)}>
|
|
{imageLabel}
|
|
<FileIcon file={file}/>
|
|
</div>
|
|
</Link>
|
|
}
|
|
}
|
|
}
|
|
|
|
export function InfoPopup({
|
|
icon = 'info circle',
|
|
size = null,
|
|
content,
|
|
position = 'left center',
|
|
iconSize = null,
|
|
header = '',
|
|
iconStyle = { margin: '0.5em'},
|
|
...props
|
|
}) {
|
|
return <Tooltip label={header ? <><strong>{header}</strong><br/>{content}</> : content}
|
|
multiline w={260} {...props}>
|
|
<span style={iconStyle}><Icon name={icon} size={iconSize || 'small'}/></span>
|
|
</Tooltip>
|
|
}
|
|
|
|
export function InfoHeader({
|
|
icon,
|
|
headerSize = 'h2',
|
|
iconSize,
|
|
headerContent,
|
|
popupContent,
|
|
popupPosition = null,
|
|
popupProps = {},
|
|
for_ = null,
|
|
required = false,
|
|
...props
|
|
}) {
|
|
/*
|
|
* The icon goes in the heading's own `after` slot. It used to be a sibling `<span>`
|
|
* beside a `<label>`, held on one line by `.inline-header h1..h5 {display: inline-block}`
|
|
* -- which worked while these headings were a bare `<h2>` and stopped working the moment
|
|
* they gained a block wrapper: the label's block child forced a break and every help icon
|
|
* in the app dropped to its own line.
|
|
*
|
|
* The label stays, now inside the heading, because `for_` associates the text with a
|
|
* field on three of these call sites.
|
|
*/
|
|
/*
|
|
* A `<label>` ONLY when `for_` names a field. Several call sites pass an InfoHeader as
|
|
* the `label` prop of InputForm/ToggleForm, which wraps whatever it is given in a
|
|
* `<label>` of its own -- an unconditional label here nests one inside another, which is
|
|
* invalid HTML and confuses what clicking the text does. That predates this change; it
|
|
* is fixed here rather than carried forward.
|
|
*
|
|
* `margin: 0` on the icon because the row's `gap` does the spacing now. The 0.5em
|
|
* default was there to separate a free-floating sibling, and inside the row it adds to
|
|
* the gap on every side.
|
|
*/
|
|
return <Header as={headerSize} {...props} after={
|
|
<InfoPopup content={popupContent} iconSize={iconSize} icon={icon} position={popupPosition}
|
|
iconStyle={{margin: 0}} {...popupProps}/>
|
|
}>
|
|
{for_
|
|
? <label htmlFor={for_}>{headerContent} {required && <RequiredAsterisk/>}</label>
|
|
: <>{headerContent} {required && <RequiredAsterisk/>}</>}
|
|
</Header>
|
|
}
|
|
|
|
export function HelpModal({
|
|
icon = 'help circle',
|
|
iconSize = null,
|
|
iconStyle = {margin: '0.5em'},
|
|
helpPath,
|
|
title = null,
|
|
modalSize = 'fullscreen',
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const src = `${HELP_VIEWER_URI}${helpPath}`;
|
|
const style = {position: 'relative', height: '75vh', width: '100%', border: 'none'};
|
|
|
|
return <>
|
|
<IconButton icon={icon} label={title || 'Help'} variant='subtle'
|
|
style={iconStyle} onClick={() => setOpen(true)}/>
|
|
<Modal open={open} onClose={() => setOpen(false)} size={modalSize} closeIcon>
|
|
{title && <Modal.Header>{title}</Modal.Header>}
|
|
<Modal.Content>
|
|
<IframeViewer title={title} src={src} style={style}/>
|
|
</Modal.Content>
|
|
</Modal>
|
|
</>
|
|
}
|
|
|
|
export function HelpHeader({
|
|
icon,
|
|
headerSize = 'h2',
|
|
iconSize,
|
|
headerContent,
|
|
helpPath,
|
|
helpTitle,
|
|
helpModalProps = {},
|
|
for_ = null,
|
|
required = false,
|
|
...props
|
|
}) {
|
|
// See the notes in InfoHeader: the control belongs in the heading's row, a label only
|
|
// when it names a field, and the row's gap is the spacing.
|
|
return <Header as={headerSize} {...props} after={
|
|
<HelpModal helpPath={helpPath} iconSize={iconSize} icon={icon} title={helpTitle}
|
|
iconStyle={{margin: 0}} {...helpModalProps}/>
|
|
}>
|
|
{for_
|
|
? <label htmlFor={for_}>{headerContent} {required && <RequiredAsterisk/>}</label>
|
|
: <>{headerContent} {required && <RequiredAsterisk/>}</>}
|
|
</Header>
|
|
}
|
|
|
|
// All the hardware toggles share this shape: a Toggle whose state comes from a
|
|
// subsystem hook, disabled when the status is unknown, with an InfoPopup explaining
|
|
// why it cannot be used. `confirmStop` asks before switching off.
|
|
function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
|
|
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
|
const unsupported = on === null;
|
|
|
|
const handleChange = (checked) => {
|
|
if (!checked && confirmStop) {
|
|
setConfirmOpen(true);
|
|
} else {
|
|
onChange(checked);
|
|
}
|
|
}
|
|
|
|
const handleConfirm = (e) => {
|
|
if (e) {
|
|
e.preventDefault()
|
|
}
|
|
setConfirmOpen(false);
|
|
onChange(false);
|
|
}
|
|
|
|
const popup = unsupported ? unsupportedMessage : info;
|
|
|
|
return <div style={{margin: '0.5em'}}>
|
|
{confirmStop && <Confirm
|
|
open={confirmOpen}
|
|
onCancel={() => setConfirmOpen(false)}
|
|
onConfirm={handleConfirm}
|
|
title={confirmStop.header}
|
|
confirmLabel={confirmStop.button}
|
|
destructive
|
|
>
|
|
{confirmStop.content}
|
|
</Confirm>}
|
|
<Toggle
|
|
label={label}
|
|
disabled={disabled === null ? unsupported : disabled}
|
|
checked={on === true}
|
|
onChange={handleChange}
|
|
/>
|
|
{popup && <InfoPopup content={popup}/>}
|
|
</div>;
|
|
}
|
|
|
|
export function HotspotToggle() {
|
|
const {on, setHotspot} = useHotspot();
|
|
return <SubsystemToggle
|
|
label='WiFi Hotspot'
|
|
on={on}
|
|
onChange={setHotspot}
|
|
unsupportedMessage='Hotspot is not supported on this server'
|
|
/>;
|
|
}
|
|
|
|
export function ThrottleToggle() {
|
|
const {on, setThrottle} = useThrottle();
|
|
return <SubsystemToggle
|
|
label='CPU Power-save'
|
|
on={on}
|
|
onChange={setThrottle}
|
|
unsupportedMessage='CPU Power-save is not supported on this server'
|
|
/>;
|
|
}
|
|
|
|
export function BluetoothToggle() {
|
|
const {on, setBluetooth} = useBluetooth();
|
|
return <SubsystemToggle
|
|
label='Bluetooth'
|
|
on={on}
|
|
onChange={setBluetooth}
|
|
unsupportedMessage='Bluetooth is not supported on this server'
|
|
/>;
|
|
}
|
|
|
|
export function DesktopToggle() {
|
|
const {on, setDesktop} = useDesktop();
|
|
return <SubsystemToggle
|
|
label='Desktop'
|
|
on={on}
|
|
onChange={setDesktop}
|
|
unsupportedMessage='The Desktop is not supported on this server'
|
|
// Stopping the desktop kills any session on this WROLPi's own screen.
|
|
confirmStop={{
|
|
header: 'Stop the desktop',
|
|
content: "Anyone using this WROLPi's own screen will lose their session and the display will"
|
|
+ ' drop to a terminal. The desktop will return on the next reboot. Are you sure?',
|
|
button: 'Stop',
|
|
}}
|
|
/>;
|
|
}
|
|
|
|
export function VncToggle() {
|
|
const {on, desktopRunning, setVnc} = useVnc();
|
|
const unsupported = on === null;
|
|
// VNC serves the desktop session; it cannot be started without one. Stopping
|
|
// stays available so a running VNC server is never stranded on.
|
|
const blockedByDesktop = !desktopRunning && on !== true;
|
|
|
|
let info = null;
|
|
if (blockedByDesktop) {
|
|
info = 'Start the Desktop before starting VNC';
|
|
} else if (on === true && !desktopRunning) {
|
|
info = 'The Desktop is stopped, so VNC clients will see nothing';
|
|
}
|
|
|
|
return <SubsystemToggle
|
|
label='VNC'
|
|
on={on}
|
|
onChange={setVnc}
|
|
unsupportedMessage='VNC is not supported on this server'
|
|
info={info}
|
|
disabled={unsupported || blockedByDesktop}
|
|
/>;
|
|
}
|
|
|
|
export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) {
|
|
// Keeps this call signature -- `onChange` receives the new boolean, not an event --
|
|
// because a dozen call sites are written against it.
|
|
const body = <span style={{display: 'inline-flex', alignItems: 'center', gap: 6}}>
|
|
<UIToggle
|
|
label={<span style={{display: 'inline-flex', alignItems: 'center', gap: 6}}>
|
|
{icon && <Icon name={icon}/>}
|
|
{label}
|
|
</span>}
|
|
checked={checked === true}
|
|
disabled={disabled === true}
|
|
onChange={(e) => onChange && onChange(e.currentTarget.checked)}
|
|
data-testid='toggle'
|
|
/>
|
|
{info && <InfoPopup content={info}/>}
|
|
</span>;
|
|
|
|
if (popupContent) {
|
|
return <Tooltip label={popupContent}>{body}</Tooltip>
|
|
}
|
|
return body
|
|
}
|
|
|
|
export function DisableDownloadsToggle() {
|
|
const [pending, setPending] = React.useState(false);
|
|
const {status, fetchStatus} = React.useContext(StatusContext);
|
|
|
|
const {downloads} = status ? status : {downloads: null};
|
|
const wrolModeEnabled = useWROLMode();
|
|
|
|
const setDownloads = async (enable) => {
|
|
setPending(true);
|
|
if (enable) {
|
|
await startDownloads();
|
|
} else {
|
|
await killDownloads();
|
|
}
|
|
await fetchStatus();
|
|
setPending(false);
|
|
}
|
|
|
|
const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false;
|
|
return <Toggle
|
|
label={on === true ? 'Downloading Enabled' : 'Downloading Disabled'}
|
|
disabled={wrolModeEnabled || pending || downloads === null}
|
|
checked={on === true}
|
|
onChange={setDownloads}
|
|
/>;
|
|
}
|
|
|
|
export function emptyToNull(obj) {
|
|
const keys = Object.keys(obj);
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
if (obj[key] === '') {
|
|
obj[key] = null;
|
|
}
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
export function mimetypeColor(mimetype, path = '') {
|
|
if (!mimetype) {
|
|
return 'grey';
|
|
}
|
|
try {
|
|
const lowerPath = path.toLowerCase();
|
|
if (mimetype === 'application/pdf') {
|
|
return 'red'
|
|
} else if (mimetype.startsWith('video/')) {
|
|
return 'blue'
|
|
} else if (mimetype.startsWith('image/')) {
|
|
return 'pink'
|
|
} else if (mimetype.startsWith('text/html')) {
|
|
return 'green'
|
|
} else if (mimetype.startsWith('application/epub')) {
|
|
return 'yellow'
|
|
} else if (mimetype.includes('cbz') || mimetype.includes('cbr')
|
|
|| mimetype.includes('comicbook')
|
|
|| lowerPath.endsWith('.cbz') || lowerPath.endsWith('.cbr')
|
|
|| lowerPath.endsWith('.cbt') || lowerPath.endsWith('.cb7')) {
|
|
return 'yellow'
|
|
} else if (mimetype.startsWith('audio/')) {
|
|
return 'violet'
|
|
} else if (mimetype.startsWith('model/') || mimetype.startsWith('application/x-openscad') || mimetype.startsWith('application/sla')
|
|
|| lowerPath.endsWith('.stl') || lowerPath.endsWith('.3mf') || lowerPath.endsWith('.obj') || lowerPath.endsWith('.scad')) {
|
|
return 'teal'
|
|
} else if (isZipMimetype(mimetype)) {
|
|
return 'purple'
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
console.error('Unable to choose mimetype color');
|
|
}
|
|
return 'grey'
|
|
}
|
|
|
|
export function isZipMimetype(mimetype) {
|
|
return mimetype.startsWith('application/zip') || mimetype.startsWith('application/zlib') || mimetype.startsWith('application/x-7z-compressed') || mimetype.startsWith('application/x-bzip2') || mimetype.startsWith('application/x-xz') || mimetype.startsWith('application/gzip') || mimetype.startsWith('application/x-rar');
|
|
}
|
|
|
|
export function isSupportedArchive(mimetype, lowerPath) {
|
|
// Zip is always supported.
|
|
if (mimetype.startsWith('application/zip') || mimetype.startsWith('application/zlib')) return true;
|
|
if (mimetype.startsWith('application/x-tar')) return true;
|
|
// Suffix-based detection for tar variants whose mimetype may be the compression type.
|
|
if (lowerPath.endsWith('.tar') || lowerPath.endsWith('.tar.gz') || lowerPath.endsWith('.tgz') || lowerPath.endsWith('.tar.bz2') || lowerPath.endsWith('.tar.xz')) return true;
|
|
// 7z and RAR archives.
|
|
if (mimetype.startsWith('application/x-7z-compressed')) return true;
|
|
if (mimetype.startsWith('application/x-rar') || mimetype.startsWith('application/vnd.rar')) return true;
|
|
if (lowerPath.endsWith('.7z') || lowerPath.endsWith('.rar')) return true;
|
|
return false;
|
|
}
|
|
|
|
export function mimetypeIconName(mimetype, lowerPath = '') {
|
|
if (mimetype) {
|
|
if (mimetype.startsWith('text/html') || mimetype.startsWith('application/json') || mimetype.startsWith('text/yaml') || mimetype.startsWith('text/xml')) {
|
|
return 'file code';
|
|
} else if (mimetype.startsWith('application/pdf')) {
|
|
return 'file pdf';
|
|
} else if (mimetype.startsWith('text/plain')) {
|
|
return 'file text';
|
|
} else if (mimetype.startsWith('image/')) {
|
|
return 'image';
|
|
} else if (mimetype.startsWith('video/')) {
|
|
return 'film';
|
|
} else if (mimetype.startsWith('message/rfc822')) {
|
|
return 'mail';
|
|
} else if (mimetype.startsWith('application/x-openscad') || mimetype.startsWith('model/')
|
|
|| lowerPath.endsWith('.stl') || lowerPath.endsWith('.3mf') || lowerPath.endsWith('.obj') || lowerPath.endsWith('.scad')) {
|
|
return 'cube';
|
|
} else if (isZipMimetype(mimetype)) {
|
|
return 'file archive';
|
|
} else if (mimetype.startsWith('application/x-iso9660-image') || mimetype.startsWith('application/x-raw-disk-image') || mimetype.startsWith('application/x-cd-image')) {
|
|
return 'dot circle';
|
|
} else if (mimetype.startsWith('application/epub') || mimetype.startsWith('application/x-mobipocket-ebook') || mimetype.startsWith('application/vnd.amazon.mobi8-ebook')
|
|
|| mimetype.startsWith('application/vnd.comicbook') || mimetype.startsWith('application/x-cbz') || mimetype.startsWith('application/x-cbr')) {
|
|
return 'book';
|
|
} else if (mimetype.startsWith('text/vtt') || mimetype.startsWith('text/srt')) {
|
|
return 'closed captioning';
|
|
} else if (mimetype.startsWith('application/x-dosexec') || mimetype.startsWith('application/x-msi') || mimetype.startsWith('application/vnd.microsoft.portable-executable')) {
|
|
return 'windows';
|
|
} else if (mimetype.startsWith('audio/')) {
|
|
return 'file audio';
|
|
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.wordprocessingml.document')) {
|
|
return 'file word';
|
|
} else if (mimetype.startsWith('application/x-x509-ca-cert')) {
|
|
return 'certificate';
|
|
} else if (mimetype.startsWith('application/x-pie-executable')) {
|
|
return 'linux';
|
|
} else if (mimetype.startsWith('application/octet-stream')) {
|
|
if (lowerPath.endsWith('.mp3')) {
|
|
return 'file audio';
|
|
} else if (lowerPath.endsWith('.stl')) {
|
|
return 'cube';
|
|
} else if (lowerPath.endsWith('.blend')) {
|
|
return 'cube';
|
|
} else if (lowerPath.endsWith('.dmg')) {
|
|
return 'apple';
|
|
} else if (lowerPath.endsWith('.azw3')) {
|
|
return 'book';
|
|
} else if (lowerPath.endsWith('.exe')) {
|
|
return 'windows';
|
|
}
|
|
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.spreadsheetml.') || mimetype.startsWith('application/vnd.ms-excel') || mimetype.startsWith('application/vnd.oasis.opendocument.spreadsheet')) {
|
|
return 'file excel'
|
|
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.wordprocessingml.') || mimetype.startsWith('application/msword') || mimetype.startsWith('application/vnd.oasis.opendocument.text')) {
|
|
return 'file word'
|
|
} else if (mimetype.startsWith('application/vnd.openxmlformats-officedocument.presentationml.') || mimetype.startsWith('application/vnd.ms-powerpoint') || mimetype.startsWith('application/vnd.oasis.opendocument.presentation')) {
|
|
return 'file powerpoint'
|
|
} else if (mimetype.startsWith('font/') || mimetype.startsWith('application/font-sfnt') || mimetype.startsWith('application/vnd.ms-fontobject')) {
|
|
return 'font'
|
|
}
|
|
}
|
|
if (lowerPath.endsWith('.pem')) {
|
|
return 'certificate';
|
|
}
|
|
return 'file';
|
|
}
|
|
|
|
const SUFFIX_ICON_MAP = {
|
|
// Code
|
|
html: 'file code', htm: 'file code', json: 'file code', xml: 'file code',
|
|
yaml: 'file code', yml: 'file code', js: 'file code', css: 'file code',
|
|
py: 'file code', sh: 'file code', c: 'file code', cpp: 'file code',
|
|
h: 'file code', java: 'file code', rs: 'file code', go: 'file code',
|
|
ts: 'file code', tsx: 'file code', jsx: 'file code',
|
|
// Documents
|
|
pdf: 'file pdf',
|
|
txt: 'file text', md: 'file text', csv: 'file text', log: 'file text', rst: 'file text',
|
|
doc: 'file word', docx: 'file word', odt: 'file word',
|
|
xls: 'file excel', xlsx: 'file excel', ods: 'file excel',
|
|
ppt: 'file powerpoint', pptx: 'file powerpoint', odp: 'file powerpoint',
|
|
// Media
|
|
jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', svg: 'image',
|
|
bmp: 'image', webp: 'image', ico: 'image', tiff: 'image',
|
|
mp4: 'film', mkv: 'film', avi: 'film', mov: 'film', wmv: 'film', webm: 'film', flv: 'film',
|
|
mp3: 'file audio', wav: 'file audio', flac: 'file audio', ogg: 'file audio',
|
|
aac: 'file audio', wma: 'file audio', m4a: 'file audio',
|
|
vtt: 'closed captioning', srt: 'closed captioning',
|
|
// Archives
|
|
zip: 'file archive', tar: 'file archive', gz: 'file archive', bz2: 'file archive',
|
|
xz: 'file archive', '7z': 'file archive', rar: 'file archive', tgz: 'file archive',
|
|
// Books
|
|
epub: 'book', mobi: 'book', azw3: 'book',
|
|
// 3D
|
|
stl: 'cube', blend: 'cube', scad: 'cube',
|
|
// Platform
|
|
exe: 'windows', msi: 'windows',
|
|
dmg: 'apple',
|
|
iso: 'dot circle', img: 'dot circle',
|
|
// Certs
|
|
pem: 'certificate', crt: 'certificate', cer: 'certificate',
|
|
// Email
|
|
eml: 'mail',
|
|
};
|
|
|
|
export function fileSuffixIconName(filename) {
|
|
const dot = filename.lastIndexOf('.');
|
|
if (dot >= 0) {
|
|
const ext = filename.substring(dot + 1).toLowerCase();
|
|
return SUFFIX_ICON_MAP[ext] || 'file';
|
|
}
|
|
return 'file';
|
|
}
|
|
|
|
export function FileIcon({file, disabled = true, size = 48, ...props}) {
|
|
const {mimetype, path, primary_path} = file;
|
|
// `file` may be a file_group or a file.
|
|
const lowerPath = primary_path ? primary_path.toLocaleString() : path.toLowerCase();
|
|
const name = mimetypeIconName(mimetype, lowerPath);
|
|
// The mimetype picks a hue; the token picks what that hue is in this theme.
|
|
const color = mimetypeColor(mimetype, lowerPath);
|
|
return <Icon
|
|
name={name}
|
|
size={size}
|
|
style={{color: `var(--${color})`, opacity: disabled ? 0.75 : 1}}
|
|
{...props}
|
|
/>
|
|
}
|
|
|
|
/**
|
|
* The severity of a load average: a warning above half the cores, a problem above three
|
|
* quarters, and nothing to say below that or when the core count is unknown.
|
|
*
|
|
* Exported and pure so the branches can be tested as branches. Reading the color back off
|
|
* a rendered Statistic cannot do it -- jsdom rejects `color: var(--danger)` as an invalid
|
|
* declaration and drops it, so every value comes back as the empty string.
|
|
*/
|
|
export const loadRole = (value, cores) => {
|
|
if (!cores) return undefined;
|
|
const quarter = cores / 4;
|
|
if (value >= quarter * 3) return 'danger';
|
|
if (value >= quarter * 2) return 'warning';
|
|
return undefined;
|
|
};
|
|
|
|
export function LoadStatistic({label, value, cores, ...props}) {
|
|
const color = loadRole(value, cores);
|
|
return <Statistic
|
|
label={label}
|
|
value={value ? parseFloat(value).toFixed(1) : '?'}
|
|
color={color}
|
|
{...props}/>;
|
|
}
|
|
|
|
export function DarkModeToggle() {
|
|
const {savedTheme, setTheme} = useContext(ThemeContext);
|
|
const active = themeChoices.find(i => i.value === savedTheme) || themeChoices[0];
|
|
|
|
return <Menu position='bottom-end' withinPortal>
|
|
<Menu.Target>
|
|
<IconButton
|
|
icon={active.icon}
|
|
label={`Theme: ${active.text}`}
|
|
variant='subtle'
|
|
style={{marginRight: '0.8em'}}
|
|
/>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Label>Theme</Menu.Label>
|
|
{themeChoices.map(choice => <Menu.Item
|
|
key={choice.value}
|
|
leftSection={<Icon name={choice.icon}/>}
|
|
onClick={() => setTheme(choice.value)}
|
|
// The chosen theme is marked by weight as well as color, so the mark
|
|
// survives in the monochrome themes.
|
|
style={choice.value === active.value ? {fontWeight: 700} : undefined}
|
|
>
|
|
{choice.text}
|
|
</Menu.Item>)}
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
}
|
|
|
|
|
|
export function UnsupportedModal(header, message, icon) {
|
|
const [open, setOpen] = useState(false);
|
|
const onOpen = () => setOpen(true);
|
|
const onClose = () => setOpen(false);
|
|
|
|
const modal = <Modal open={open} onClose={onClose} size='tiny'>
|
|
<Modal.Header>
|
|
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
|
|
<Icon name={icon || 'exclamation triangle'}/>
|
|
{header || 'Unsupported'}
|
|
</span>
|
|
</Modal.Header>
|
|
<Modal.Content>{message}</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={onClose}>Ok</Button>
|
|
</Modal.Actions>
|
|
</Modal>;
|
|
|
|
return {modal, doClose: onClose, doOpen: onOpen};
|
|
}
|
|
|
|
/**
|
|
* The hotspot glyph in the nav bar: a wifi icon with a corner mark for the state.
|
|
* A button, not an anchor -- every one of these opens a dialog or toggles the
|
|
* hotspot, and `<a href='#'>` was neither keyboard-honest nor navigable.
|
|
*/
|
|
function HotspotButton({label, corner, on, onClick}) {
|
|
const wifi = <Icon name='wifi' size='large' style={{opacity: on ? 1 : 0.55}}/>;
|
|
return <IconButton label={label} variant='subtle' onClick={onClick} icon={() =>
|
|
corner
|
|
? <IconStack corner={<Icon name={corner} size={12}/>} label={label}>{wifi}</IconStack>
|
|
: wifi
|
|
}/>
|
|
}
|
|
|
|
export function HotspotStatusIcon() {
|
|
const {on, inUse, setHotspot, dockerized, hotspotSsid} = useHotspot();
|
|
const {modal: unsupportedModal, doOpen: openUnsupportedModal} =
|
|
UnsupportedModal('Unsupported', 'You cannot toggle the hotspot on this machine.');
|
|
const [stopHotspotOpen, setStopHotspotOpen] = React.useState(false);
|
|
const [inUseOpen, setInUseOpen] = React.useState(false);
|
|
|
|
const handleConfirmStop = (e) => {
|
|
if (e) {
|
|
e.preventDefault()
|
|
}
|
|
setStopHotspotOpen(false);
|
|
setHotspot(false);
|
|
}
|
|
|
|
const handleConfirmInUse = (e) => {
|
|
if (e) {
|
|
e.preventDefault()
|
|
}
|
|
setInUseOpen(false);
|
|
setHotspot(true);
|
|
}
|
|
|
|
if (inUse === true) {
|
|
const content = hotspotSsid ? `Wifi device is in use for ${hotspotSsid}. Disconnect and start hotspot?`
|
|
: 'Wifi device is in use. Disconnect and start hotspot?'
|
|
return <>
|
|
<Confirm open={inUseOpen}
|
|
onCancel={() => setInUseOpen(false)}
|
|
onConfirm={handleConfirmInUse}
|
|
title='Wifi is in-use'
|
|
confirmLabel='Start Hotspot'
|
|
>
|
|
{content}
|
|
</Confirm>
|
|
<HotspotButton label='Wifi is in use; start the hotspot' corner='exclamation'
|
|
onClick={() => setInUseOpen(true)}/>
|
|
</>
|
|
} else if (dockerized === false && on === true) {
|
|
return <>
|
|
<Confirm
|
|
open={stopHotspotOpen}
|
|
onCancel={() => setStopHotspotOpen(false)}
|
|
onConfirm={handleConfirmStop}
|
|
title='Stop the hotspot'
|
|
confirmLabel='Stop'
|
|
destructive
|
|
>
|
|
You will be disconnected when using the hotspot. Are you sure?
|
|
</Confirm>
|
|
<HotspotButton label='Hotspot is on; stop it' on
|
|
onClick={() => setStopHotspotOpen(true)}/>
|
|
</>
|
|
} else if (dockerized === false && on === false) {
|
|
return <HotspotButton label='Hotspot is off; start it' corner='x'
|
|
onClick={() => setHotspot(true)}/>
|
|
}
|
|
|
|
// Hotspot is not available, or, status has not yet been fetched.
|
|
return <>
|
|
<HotspotButton label='Hotspot status is unknown' corner='question'
|
|
onClick={openUnsupportedModal}/>
|
|
{unsupportedModal}
|
|
</>
|
|
}
|
|
|
|
export function useMediaSession(title, artist, artworkUrl) {
|
|
useEffect(() => {
|
|
if ('mediaSession' in navigator && title) {
|
|
const artwork = [];
|
|
if (artworkUrl) {
|
|
artwork.push({src: artworkUrl, type: 'image/jpeg'});
|
|
}
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: title,
|
|
artist: artist || '',
|
|
artwork: artwork,
|
|
});
|
|
}
|
|
}, [title, artist, artworkUrl]);
|
|
}
|
|
|
|
export function useTitle(title) {
|
|
const documentDefined = typeof document !== 'undefined';
|
|
const originalTitle = React.useRef(documentDefined ? document.title : null);
|
|
const name = NAME ? `${NAME} WROLPi` : `WROLPi`;
|
|
|
|
useEffect(() => {
|
|
if (!documentDefined) {
|
|
return;
|
|
}
|
|
|
|
const newTitle = `${title} - ${name}`
|
|
if (title && document.title !== newTitle) {
|
|
document.title = newTitle;
|
|
}
|
|
return () => {
|
|
document.title = originalTitle.current;
|
|
}
|
|
}, [title]);
|
|
}
|
|
|
|
export function DirectorySearch({onSelect, value, disabled, required, ...props}) {
|
|
const {
|
|
directoryName,
|
|
setDirectoryName,
|
|
directories,
|
|
channelDirectories,
|
|
domainDirectories,
|
|
isDir,
|
|
loading,
|
|
} = useSearchDirectories(value);
|
|
const [results, setResults] = useState({});
|
|
|
|
useEffect(() => {
|
|
if (directories && directories.length >= 0) {
|
|
const newDirectory = isDir ? {} : {
|
|
newDirectory: {
|
|
name: 'New Directory',
|
|
results: [{title: directoryName || ''}],
|
|
}
|
|
};
|
|
const newResults = {
|
|
...newDirectory,
|
|
directories: {
|
|
name: 'Directories',
|
|
results: directories.map(i => {
|
|
return {title: i['path']}
|
|
}),
|
|
},
|
|
channel_directories: {
|
|
name: 'Channels',
|
|
results: channelDirectories.map(i => {
|
|
return {title: i['path'], description: i['name']};
|
|
}),
|
|
},
|
|
domain_directories: {
|
|
name: 'Domains',
|
|
results: domainDirectories.map(i => {
|
|
return {title: i['path'], description: i['domain']};
|
|
}),
|
|
},
|
|
};
|
|
setResults(newResults);
|
|
}
|
|
}, [
|
|
JSON.stringify(directories),
|
|
JSON.stringify(channelDirectories),
|
|
JSON.stringify(domainDirectories),
|
|
loading,
|
|
]);
|
|
|
|
const handleSearchChange = (value) => {
|
|
setDirectoryName(value);
|
|
}
|
|
|
|
const handleResultSelect = (result) => {
|
|
setDirectoryName(result.title);
|
|
// title is the relative path.
|
|
if (onSelect) {
|
|
onSelect(result.title);
|
|
}
|
|
}
|
|
|
|
const handleBlur = (e) => {
|
|
// When user leaves the field, commit the typed value to the form
|
|
if (onSelect && directoryName !== value) {
|
|
onSelect(directoryName);
|
|
}
|
|
}
|
|
|
|
return <SearchBox
|
|
placeholder='Search directory names...'
|
|
onChange={handleSearchChange}
|
|
onResultSelect={handleResultSelect}
|
|
onBlur={handleBlur}
|
|
loading={loading}
|
|
value={directoryName || ''}
|
|
results={results}
|
|
disabled={disabled}
|
|
required={required}
|
|
{...props}
|
|
/>
|
|
}
|
|
|
|
export const BackButton = ({...props}) => {
|
|
const navigate = useNavigate();
|
|
return <Button role='cancel' icon='arrow left' onClick={() => navigate(-1)} {...props}>Back</Button>;
|
|
}
|
|
|
|
export const filterToMimetypes = (filter) => {
|
|
const zipMimetypes = ['application/zip', 'application/zlib', 'application/x-bzip2', 'application/x-xz', 'application/x-bzip', 'application/x-bzip2', 'application/gzip', 'application/vnd.rar', 'application/x-tar', 'application/x-7z-compressed'];
|
|
const softwareMimetypes = [...zipMimetypes, 'application/x-iso9660-image', 'application/x-executable', 'application/x-dosexec'];
|
|
|
|
if (filter === 'video') {
|
|
return ['video'];
|
|
} else if (filter === 'archive') {
|
|
return ['text/html'];
|
|
} else if (filter === 'pdf') {
|
|
return ['application/pdf'];
|
|
} else if (filter === 'audio') {
|
|
return ['audio'];
|
|
} else if (filter === 'ebook') {
|
|
return ['application/epub+zip', 'application/x-mobipocket-ebook'];
|
|
} else if (filter === 'doc') {
|
|
return ['application/epub+zip', 'application/x-mobipocket-ebook', 'application/pdf',
|
|
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.oasis.opendocument.text', 'application/x-cbz', 'application/x-cbr'];
|
|
} else if (filter === 'image') {
|
|
return ['image'];
|
|
} else if (filter === 'zip') {
|
|
return zipMimetypes;
|
|
} else if (filter === 'model') {
|
|
return ['application/x-openscad', 'model/stl', 'application/sla', 'model/obj', 'model/3mf'];
|
|
} else if (filter === 'software') {
|
|
return softwareMimetypes;
|
|
}
|
|
}
|
|
|
|
export const toLocaleString = (num, locale = 'en-US') => {
|
|
return num.toLocaleString(locale);
|
|
}
|
|
|
|
/*
|
|
* The WCAG primitives now live in themes/contrast, so the theme code can measure a color
|
|
* without importing this module -- Common.js pulls in half the component library, and
|
|
* `themes/` importing it is how an import cycle starts. Forwarded because a good many call
|
|
* sites and tests already take `contrastRatio` from here.
|
|
*
|
|
* It is forwarded by a function that delegates rather than by `export {contrastRatio}`, and
|
|
* that is load-bearing. A bare re-export compiles to a getter that dereferences the imported
|
|
* module, and six specs mock this module with `{...jest.requireActual('./Common')}` -- a
|
|
* spread invokes every getter, and those spreads run re-entrantly while Common.js is still
|
|
* evaluating (Common -> customHooks -> Common, both mocked that way). The getter fires
|
|
* before the import binding is assigned, throws `Cannot read properties of undefined`, and
|
|
* takes the whole suite with it. A function is read by the spread but not called, so it
|
|
* dereferences nothing until evaluation has finished.
|
|
*
|
|
* The hex parser accepting the `#rgb` shorthand matters and is not incidental: tag colors
|
|
* are not only chosen in the color picker, they live in a config file a user edits by hand
|
|
* -- configs are the source of truth in WROLPi. Rejecting `#fff` left the luminance at zero,
|
|
* so a near-white tag was treated as black and handed light text.
|
|
*/
|
|
export function contrastRatio(a, b) {
|
|
return measureContrast(a, b);
|
|
}
|
|
|
|
export const TAG_TEXT_DARK = '#000000';
|
|
export const TAG_TEXT_LIGHT = '#dddddd';
|
|
|
|
/**
|
|
* Dark or light text, whichever a user's chosen tag color actually reads better against.
|
|
*
|
|
* Measures both instead of comparing a brightness figure to a threshold. The threshold
|
|
* version put light text on Semantic's blue (`#2185d0`) at 2.9:1 when dark text on the same
|
|
* fill gives 5.3:1 -- it had picked the worse of the only two options. Every mid-tone blue,
|
|
* teal and purple a user might choose sat in that band.
|
|
*/
|
|
export function contrastingColor(color) {
|
|
return contrastRatio(TAG_TEXT_DARK, color) >= contrastRatio(TAG_TEXT_LIGHT, color)
|
|
? TAG_TEXT_DARK
|
|
: TAG_TEXT_LIGHT;
|
|
}
|
|
|
|
export const encodeMediaPath = (path) => {
|
|
// Replace % first to avoid replacing the other replacements.
|
|
path = path.replaceAll('%', '%25');
|
|
|
|
path = path.replaceAll('&', '%26');
|
|
path = path.replaceAll('=', '%3d');
|
|
path = path.replaceAll('#', '%23');
|
|
path = path.replaceAll(' ', '%20');
|
|
return path
|
|
}
|
|
|
|
export function SortButton({sorts = []}) {
|
|
const {sort, setSort} = useSearchOrder();
|
|
|
|
if (!sorts || (sorts && sorts.length === 0)) {
|
|
console.error('No sorts have been defined!');
|
|
}
|
|
|
|
const [localSort, setLocalSort] = useState(sort ? sort.replaceAll(/^-/g, '') : null);
|
|
const [desc, setDesc] = useState(sort ? sort.startsWith('-') : true);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// Remove the - from the front of the query sort, it will be added when toggling direction.
|
|
const sortKey = localSort ? localSort.replaceAll(/^-/g, '') : sorts[0]['value'];
|
|
const selectedSort = sorts.find(i => i['value'] === sortKey);
|
|
|
|
useEffect(() => {
|
|
if (localSort) {
|
|
const newSort = desc ? `-${localSort}` : localSort;
|
|
console.debug(`Setting new sort: ${newSort}`)
|
|
setSort(newSort);
|
|
}
|
|
}, [localSort, desc]);
|
|
|
|
const handleSortButton = (o) => {
|
|
setLocalSort(o);
|
|
setOpen(false);
|
|
}
|
|
|
|
const toggleDesc = () => {
|
|
setDesc(!desc);
|
|
if (!localSort) {
|
|
// No sort in URL, use the first.
|
|
setLocalSort(sorts[0]['value']);
|
|
}
|
|
setOpen(false);
|
|
}
|
|
|
|
let sortFields;
|
|
if (sorts && sorts.length) {
|
|
sortFields = sorts.map((i) => {
|
|
return <Button key={i['value']} role='cancel' fullWidth
|
|
onClick={() => handleSortButton(i['value'])}>{i['text']}</Button>
|
|
})
|
|
}
|
|
|
|
return <>
|
|
<Modal size='small' closeIcon
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
>
|
|
<Modal.Header>Sort By</Modal.Header>
|
|
<Modal.Content>
|
|
{sortFields}
|
|
</Modal.Content>
|
|
</Modal>
|
|
<ButtonGroup>
|
|
<IconButton
|
|
icon={desc ? 'arrow down' : 'arrow up'}
|
|
label={desc ? 'Sort descending' : 'Sort ascending'}
|
|
onClick={() => toggleDesc()}
|
|
/>
|
|
<Button role='cancel' onClick={() => setOpen(true)}>
|
|
{selectedSort['short'] || selectedSort['text']}
|
|
</Button>
|
|
</ButtonGroup>
|
|
</>
|
|
}
|
|
|
|
export function TagIcon() {
|
|
return <Label color='green' icon='tag' className='wrolpi-tag-icon'/>
|
|
}
|
|
|
|
export function normalizeEstimate(estimate) {
|
|
if (Number.isInteger(estimate)) {
|
|
return estimate > 999 ? '>999' : estimate.toString();
|
|
}
|
|
return '?';
|
|
}
|
|
|
|
export function useAPIButton(
|
|
color = 'violet',
|
|
/*
|
|
* Undefined, not 'medium'. `size` was never put into `buttonArgs` below, so every
|
|
* APIButton in the app rendered at Mantine's default no matter what its call site asked
|
|
* for -- seventeen of them pass a size, from `xs` on the map's pin actions to `huge` on
|
|
* Settings, and all seventeen were ignored. The map's pin row is where it showed: three
|
|
* buttons meant to be `xs`, one of them 36px tall next to two 30px ones.
|
|
*
|
|
* Defaulting to 'medium' and passing that through would have fixed the bug by breaking
|
|
* everything else -- every APIButton that names no size would have grown from `sm` to
|
|
* `md`. Undefined lets Button's own default stand, so only the call sites that asked for
|
|
* something see a change.
|
|
*/
|
|
size,
|
|
floated,
|
|
onClick,
|
|
disabled,
|
|
confirmContent,
|
|
confirmButton,
|
|
confirmHeader,
|
|
themed = true,
|
|
obeyWROLMode = false,
|
|
icon = null,
|
|
type = 'button',
|
|
id = null,
|
|
props
|
|
) {
|
|
props = props || {};
|
|
const ref = React.useRef();
|
|
|
|
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [showSuccess, setShowSuccess] = React.useState(false);
|
|
const [showFailure, setShowFailure] = React.useState(false);
|
|
|
|
const wrolModeEnabled = useWROLMode();
|
|
|
|
// Disable when API call is pending, or button is disabled.
|
|
disabled = loading || disabled;
|
|
// Disable when WROL Mode is enabled, otherwise normal disabled.
|
|
disabled = obeyWROLMode ? wrolModeEnabled || disabled : disabled;
|
|
|
|
const reset = () => {
|
|
setShowSuccess(false);
|
|
setShowFailure(false);
|
|
};
|
|
|
|
const setSuccess = () => {
|
|
setShowSuccess(true);
|
|
setTimeout(reset, 2000);
|
|
}
|
|
|
|
const setFailure = () => {
|
|
setShowFailure(true);
|
|
setTimeout(reset, 2000);
|
|
}
|
|
|
|
const handleAPICall = async () => {
|
|
// Handle when user clicks button, or clicks confirm.
|
|
setLoading(true);
|
|
try {
|
|
await onClick();
|
|
setSuccess();
|
|
} catch (e) {
|
|
console.error(e);
|
|
setFailure();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const localOnClick = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (confirmContent) {
|
|
// Clicking button opens confirm.
|
|
setConfirmOpen(true);
|
|
} else if (onClick) {
|
|
// No <Confirm/> send the API request.
|
|
await handleAPICall();
|
|
} else {
|
|
throw Error('No onClick defined!');
|
|
}
|
|
}
|
|
|
|
const localOnConfirm = async () => {
|
|
// User clicked the "OK" button in the <Confirm/>.
|
|
setConfirmOpen(false);
|
|
|
|
if (onClick) {
|
|
await handleAPICall();
|
|
}
|
|
}
|
|
|
|
// `color` was a Semantic color name; a role carries the meaning instead, and a
|
|
// caller that really wants a color can still pass one through `props`.
|
|
const buttonArgs = {
|
|
onClick: localOnClick, disabled, loading, type, size,
|
|
...props,
|
|
};
|
|
if (id) {
|
|
buttonArgs['id'] = id;
|
|
}
|
|
if (!props.color && !props.role) {
|
|
buttonArgs['role'] = 'primary';
|
|
}
|
|
|
|
// The result is shown as the icon rather than an animation: the outcome should be
|
|
// legible at a glance, and in night mode a moving element is the last thing wanted.
|
|
const outcomeIcon = showSuccess ? 'check' : showFailure ? 'close' : null;
|
|
// Roles are registered as Mantine colors, so these reach `color` the same way a hue
|
|
// would. This is the plainest success/failure in the app: the call worked, or it did not.
|
|
const outcomeColor = showSuccess ? 'success' : showFailure ? 'danger' : undefined;
|
|
if (outcomeColor) {
|
|
buttonArgs['color'] = outcomeColor;
|
|
}
|
|
|
|
let buttonContent = props.children || null;
|
|
if (icon) {
|
|
// Icon-only: swap the glyph for the outcome.
|
|
buttonArgs['icon'] = outcomeIcon || icon;
|
|
} else if (outcomeIcon) {
|
|
buttonArgs['icon'] = outcomeIcon;
|
|
}
|
|
|
|
let button = <Button ref={ref} {...buttonArgs}>{buttonContent}</Button>;
|
|
|
|
if (confirmContent) {
|
|
// Wrap button with <Confirm/>
|
|
button = <>
|
|
{button}
|
|
<Confirm open={confirmOpen}
|
|
onCancel={() => setConfirmOpen(false)}
|
|
onConfirm={localOnConfirm}
|
|
confirmLabel={confirmButton}
|
|
title={confirmHeader}
|
|
destructive
|
|
>
|
|
{confirmContent}
|
|
</Confirm>
|
|
</>
|
|
}
|
|
|
|
return {button, ref}
|
|
}
|
|
|
|
export function APIButton({
|
|
color,
|
|
size,
|
|
floated,
|
|
onClick,
|
|
disabled,
|
|
confirmContent,
|
|
confirmButton,
|
|
confirmHeader,
|
|
themed,
|
|
obeyWROLMode,
|
|
icon,
|
|
type = 'button',
|
|
id = null,
|
|
...props
|
|
}) {
|
|
const {button} = useAPIButton(
|
|
color,
|
|
size,
|
|
floated,
|
|
onClick,
|
|
disabled,
|
|
confirmContent,
|
|
confirmButton,
|
|
confirmHeader,
|
|
themed,
|
|
obeyWROLMode,
|
|
icon,
|
|
type,
|
|
id,
|
|
props
|
|
);
|
|
|
|
return button;
|
|
}
|
|
|
|
export const useMessageDismissal = (messageName) => {
|
|
const [dismissed, setDismissed] = useLocalStorage('dismissed_hints', {});
|
|
|
|
return {
|
|
dismissed: dismissed[messageName] || false, // true or false
|
|
setDismissed: (value) => setDismissed({...dismissed, [messageName]: !!value}), // force true/false
|
|
clearAll: () => setDismissed({}),
|
|
}
|
|
}
|
|
|
|
/** A dismissible note. `storageName` makes the dismissal stick across reloads. */
|
|
function DismissibleMessage({kind, icon, children, storageName}) {
|
|
const {dismissed, setDismissed} = useMessageDismissal(storageName);
|
|
|
|
if (dismissed) {
|
|
return <React.Fragment/>
|
|
}
|
|
|
|
return <Message
|
|
kind={kind}
|
|
icon={icon}
|
|
onDismiss={storageName ? () => setDismissed(true) : undefined}
|
|
>
|
|
{children}
|
|
</Message>
|
|
}
|
|
|
|
export function InfoMessage({children, size = null, storageName = null, icon = 'warning circle'}) {
|
|
return <DismissibleMessage kind='info' icon={icon} storageName={storageName}>
|
|
{children}
|
|
</DismissibleMessage>
|
|
}
|
|
|
|
export function HandPointMessage({children, size = null, storageName = null}) {
|
|
return <DismissibleMessage kind='info' icon='hand point right' storageName={storageName}>
|
|
{children}
|
|
</DismissibleMessage>
|
|
}
|
|
|
|
export function WarningMessage({children, size = null, icon = 'exclamation', storageName = null}) {
|
|
return <DismissibleMessage kind='warning' icon={icon} storageName={storageName}>
|
|
{children}
|
|
</DismissibleMessage>
|
|
}
|
|
|
|
export function ErrorMessage({children, size = null, icon = 'exclamation', storageName = null}) {
|
|
return <DismissibleMessage kind='error' icon={icon} storageName={storageName}>
|
|
{children}
|
|
</DismissibleMessage>
|
|
}
|
|
|
|
function levenshteinDistance(a, b) {
|
|
const matrix = [];
|
|
|
|
// Initialize the matrix
|
|
for (let i = 0; i <= b.length; i++) {
|
|
matrix[i] = [i];
|
|
}
|
|
for (let j = 0; j <= a.length; j++) {
|
|
matrix[0][j] = j;
|
|
}
|
|
|
|
// Populate the matrix
|
|
for (let i = 1; i <= b.length; i++) {
|
|
for (let j = 1; j <= a.length; j++) {
|
|
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
matrix[i][j] = matrix[i - 1][j - 1];
|
|
} else {
|
|
matrix[i][j] = Math.min(matrix[i - 1][j - 1], matrix[i][j - 1], matrix[i - 1][j]) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return matrix[b.length][a.length];
|
|
}
|
|
|
|
export function fuzzyMatch(a, b, threshold = 3) {
|
|
return levenshteinDistance(a, b) <= threshold;
|
|
}
|
|
|
|
export function useIsIgnoredDirectory(directory) {
|
|
const {settings} = React.useContext(SettingsContext);
|
|
|
|
if (!settings || _.isEmpty(settings)) {
|
|
// Settings have not yet been fetched.
|
|
return false;
|
|
}
|
|
|
|
let ignoredDirectories = settings['ignored_directories'];
|
|
if (directory.endsWith('/')) {
|
|
ignoredDirectories = ignoredDirectories.map(i => `${i}/`);
|
|
}
|
|
|
|
return ignoredDirectories.indexOf(directory) >= 0;
|
|
}
|
|
|
|
export function getParentDirectory(filePath) {
|
|
// Remove trailing slashes for consistency
|
|
const normalizedPath = filePath.endsWith('/') ? filePath.slice(0, -1) : filePath;
|
|
|
|
// Find the last occurrence of "/" and extract the substring up to it
|
|
const parentDirectory = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
|
|
|
|
return parentDirectory;
|
|
}
|
|
|
|
export function MultilineText({text, ...props}) {
|
|
return <div {...props}>
|
|
{text.split('\n').map((line, index, array) =>
|
|
index === array.length - 1 ? line : <p key={index}>{line}</p>
|
|
)}
|
|
</div>
|
|
}
|
|
|
|
export const monthNames = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December',
|
|
];
|
|
|
|
export function IframeViewer({title, src, fallback, style, timeout = 5000}) {
|
|
// This function checks that an iframe can be fetched before displaying it. Otherwise, it will display the fallback
|
|
// element which should help the maintainer to fix the issue.
|
|
const [contentAvailable, setContentAvailable] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
fallback = fallback || <pre>Frame could not load.</pre>;
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController(); // To manage fetch timeout
|
|
const timeoutId = setTimeout(() => {
|
|
controller.abort(); // Abort the fetch after 5 seconds
|
|
}, timeout);
|
|
|
|
fetch(src, {signal: controller.signal})
|
|
.then(response => {
|
|
// Only display content if it can be fetched.
|
|
setContentAvailable(response.ok);
|
|
})
|
|
.catch(() => {
|
|
setContentAvailable(false); // Handle fetch errors (including aborts)
|
|
})
|
|
.finally(() => {
|
|
setLoading(false); // Update loading state regardless of result
|
|
clearTimeout(timeoutId); // Clear the timeout
|
|
});
|
|
|
|
return () => clearTimeout(timeoutId); // Cleanup timeout on unmount
|
|
}, [src]);
|
|
|
|
// Mobile needs less height, otherwise it hides the content below the screen.
|
|
let mobileStyle = {
|
|
position: 'fixed',
|
|
height: '80%',
|
|
width: '100%',
|
|
border: 'none',
|
|
padding: 0,
|
|
backgroundColor: 'var(--panel)',
|
|
};
|
|
// Allow provided `style` to overwrite.
|
|
mobileStyle = style ? {...mobileStyle, ...style} : mobileStyle;
|
|
let tabletStyle = {...mobileStyle, height: '93%'};
|
|
tabletStyle = style ? {...tabletStyle, ...style} : tabletStyle;
|
|
|
|
const iframeMedia = <>
|
|
<Media at='mobile'>
|
|
<iframe title={title} src={src} style={mobileStyle} allow="geolocation"/>
|
|
</Media>
|
|
<Media greaterThan='mobile'>
|
|
<iframe title={title} src={src} style={tabletStyle} allow="geolocation"/>
|
|
</Media>
|
|
</>;
|
|
|
|
return <>
|
|
{loading ? <Panel><Loading/></Panel>
|
|
: contentAvailable ? iframeMedia
|
|
: fallback
|
|
}
|
|
</>
|
|
}
|
|
|
|
export function roundDigits(value, decimals = 2) {
|
|
return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
|
|
}
|
|
|
|
|
|
export function Breadcrumbs({crumbs, size = undefined}) {
|
|
function getSection(crumb, index) {
|
|
const {text, link, icon} = crumb;
|
|
if (link) {
|
|
return <Link key={index} to={link} style={{display: 'inline-flex', alignItems: 'center', gap: 4}}>
|
|
{icon && <Icon name={icon}/>}
|
|
{text}
|
|
</Link>;
|
|
}
|
|
return <span key={index}>{text}</span>
|
|
}
|
|
|
|
// The separator is a chevron rather than Semantic's slash, matching the icon set.
|
|
return <UIBreadcrumbs separator={<Icon name='chevron right' size={14}/>}>
|
|
{crumbs.map(getSection)}
|
|
</UIBreadcrumbs>
|
|
}
|
|
|
|
export function validURL(url) {
|
|
return !(url && !validUrlRegex.test(url));
|
|
}
|
|
|
|
export function validURLs(urls) {
|
|
if (!!!urls) {
|
|
// Invalid while empty.
|
|
return false;
|
|
}
|
|
urls = urls.split(/\r?\n/);
|
|
for (let i = 0; i < urls.length; i++) {
|
|
if (!validURL(urls[i])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function useLocalStorage(key, initialValue, decode = JSON.parse, encode = JSON.stringify) {
|
|
// Use localstorage to store some JSON encode-able.
|
|
|
|
// Initialize state with the value from localStorage or initial value
|
|
const [value, setValue] = useState(() => {
|
|
let item;
|
|
try {
|
|
const item = window.localStorage.getItem(key);
|
|
// Parse the stored item (integer, bool, etc.). Use the initial value if empty.
|
|
return item ? decode(item) : initialValue;
|
|
} catch (error) {
|
|
console.error('useLocalStorage', key, item, initialValue);
|
|
console.error('Error reading localStorage:', error);
|
|
return initialValue;
|
|
}
|
|
});
|
|
|
|
// Save to localStorage when the value changes
|
|
useEffect(() => {
|
|
if (key === null || key === undefined) {
|
|
// key was not defined, do not add this to storage.
|
|
return
|
|
}
|
|
try {
|
|
decode(value);
|
|
window.localStorage.setItem(key, value);
|
|
} catch (error) {
|
|
window.localStorage.setItem(key, encode(value));
|
|
}
|
|
}, [key, value, encode]);
|
|
|
|
// Return the stored value and a function to update it
|
|
return [value, setValue];
|
|
}
|
|
|
|
export function useLocalStorageInt(key, initialValue) {
|
|
// Use localStorage to store an integer.
|
|
const [storedValue, setStoredValue] = useLocalStorage(key, initialValue, parseInt, (num) => num.toString());
|
|
return [storedValue, setStoredValue];
|
|
}
|
|
|
|
export function SimpleAccordion({title = 'Advanced', ...props}) {
|
|
return <Accordion chevronPosition='left'>
|
|
<Accordion.Item value='content'>
|
|
<Accordion.Control>{title}</Accordion.Control>
|
|
<Accordion.Panel>{props.children}</Accordion.Panel>
|
|
</Accordion.Item>
|
|
</Accordion>
|
|
}
|
|
|
|
export function mergeDeep(target, source) {
|
|
if (_.isEmpty(source)) {
|
|
return target;
|
|
}
|
|
|
|
// Initialize the result as target
|
|
let result = Object.assign({}, target);
|
|
|
|
for (let key of Object.keys(source)) {
|
|
if (Array.isArray(source[key])) {
|
|
// source overwrites target if it is a list with values.
|
|
result[key] = source[key] || target[key];
|
|
} else if (typeof source[key] === 'object' && typeof target[key] === 'object' && source[key] !== null) {
|
|
result[key] = mergeDeep(target[key] || {}, source[key]);
|
|
} else {
|
|
result[key] = source[key] !== undefined ? source[key] : target[key];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function getDistinctColor(hexColors) {
|
|
function hexToHSL(hex) {
|
|
if (!hex) {
|
|
return {h: 0, s: 0, l: 0};
|
|
}
|
|
let r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
let g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
let b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
|
|
let cmax = Math.max(r, g, b), cmin = Math.min(r, g, b);
|
|
let delta = cmax - cmin;
|
|
let h, s, l = (cmax + cmin) / 2;
|
|
|
|
if (delta === 0) h = 0;
|
|
else if (cmax === r) h = ((g - b) / delta) % 6;
|
|
else if (cmax === g) h = (b - r) / delta + 2;
|
|
else h = (r - g) / delta + 4;
|
|
|
|
h = Math.round(h * 60);
|
|
if (h < 0) h += 360;
|
|
|
|
s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
|
|
return {h: h, s: s, l: l};
|
|
}
|
|
|
|
function hslToHex(h, s, l) {
|
|
l /= 100;
|
|
const a = s * Math.min(l, 1 - l) / 100;
|
|
const f = n => {
|
|
const k = (n + h / 30) % 12;
|
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
};
|
|
return `#${f(0)}${f(8)}${f(4)}`;
|
|
}
|
|
|
|
function generateRandomHSL() {
|
|
return {
|
|
h: Math.random() * 360,
|
|
s: Math.random() * 100,
|
|
l: Math.random() * 100
|
|
};
|
|
}
|
|
|
|
function isDistinct(newColor, existingColors, threshold) {
|
|
const hslNew = newColor;
|
|
for (const color of existingColors) {
|
|
const hslColor = hexToHSL(color);
|
|
const h = Math.abs(hslNew.h - hslColor.h);
|
|
const s = Math.abs(hslNew.s * 100 - hslColor.s * 100);
|
|
const l = Math.abs(hslNew.l - hslColor.l * 100);
|
|
if (h < threshold && s < threshold && l < threshold) {
|
|
console.debug(`trying distinct color h=${h} s=${s} l=${l} with threshold=${threshold}`);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
let newColor, attempt = 0;
|
|
const maxAttempts = 1000; // Max attempts before returning any color
|
|
const baseThreshold = 30; // Starting threshold
|
|
|
|
do {
|
|
newColor = generateRandomHSL();
|
|
// Decreasing threshold as attempts increase, but never below 10 for distinctiveness
|
|
let threshold = Math.max(baseThreshold - (attempt / 10), 10);
|
|
if (isDistinct(newColor, hexColors, threshold)) {
|
|
return hslToHex(newColor.h, newColor.s, newColor.l);
|
|
}
|
|
attempt++;
|
|
} while (attempt < maxAttempts);
|
|
|
|
// If we've tried maxAttempts times, return the last generated color regardless
|
|
return hslToHex(newColor.h, newColor.s, newColor.l);
|
|
}
|
|
|
|
export const RefreshHeader = ({header, headerSize = 'h2', onRefresh, popupContents}) => {
|
|
const refreshButton = <APIButton icon='refresh' onClick={onRefresh}/>;
|
|
let popup;
|
|
if (popupContents) {
|
|
popup = <Tooltip label={popupContents}>{refreshButton}</Tooltip>;
|
|
}
|
|
return <div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
|
}}>
|
|
<Header as={headerSize}>{header}</Header>
|
|
{popup || refreshButton}
|
|
</div>
|
|
}
|