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) => ;
return <>
{pager(1)}{pager(3)}
>
}
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 {elapsed}
}
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 {isoDatetimeToString(dt)}
}
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
{hours}:{minutes}:{seconds}
} else if (totalSeconds) {
return
{minutes}:{seconds}
} else {
return <>>
}
}
export function isoDatetimeToString(dt, time = false) {
// Convert a datetime to a human-readable date format.
let d = ;
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
{props.children}
}
export function ExternalCardLink({to, children, className, ...props}) {
return
{children}
}
export function PreviewLink({file, children, className, ...props}) {
const {setPreviewFile} = React.useContext(FilePreviewContext);
className = className ? `clickable ${className}` : `clickable `;
return setPreviewFile(file)} {...props}>
{children}
}
export function PreviewPath({path, mimetype, taggable = true, ...props}) {
return
}
export function DirectoryLink({path}) {
if (!path) return Unknown;
const href = `/media/${encodeMediaPath(path)}/`;
return
{path}
}
export function RequiredAsterisk() {
return *
}
/**
* 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
}
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
}
export function WROLModeMessage({content}) {
const wrolModeEnabled = useWROLMode();
if (wrolModeEnabled) {
return {content}
}
return null;
}
export function DownloadWindowMessage() {
const {status} = React.useContext(StatusContext);
const downloads = status ? status.downloads : null;
if (downloads && downloads.outside_download_window) {
return
Downloads are paused because the current time is outside the configured download window.
}
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
Downloads are paused because the daily download limit has been reached. Downloads will
resume tomorrow.
}
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 Unlock Cookies setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleUnlock()}
/>
;
}
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 <>
You have encrypted cookies stored, but they are currently locked.
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
{links.map((link) => tabClassName(link.isActive ? link.isActive() : isActive)}
>
{link.text}
)}
}
export function PageContainer(props) {
return <>
{props.children}
{props.children}
>;
}
export function CardGroupCentered(props) {
// One responsive grid; it centres what it cannot fill, so the two viewports no
// longer need separate markup.
return
{props.children}
}
/**
* 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 =
;
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}
{overlay}
>;
return
{to
// Link within this App.
? {inner}
// Preview the file.
: {inner}}
} 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
{imageLabel}
} else if (!posterPath && to) {
// Link to the full page in this App.
return