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} poster {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
navigate(to)}> {imageLabel}
} } } export function InfoPopup({ icon = 'info circle', size = null, content, position = 'left center', iconSize = null, header = '', iconStyle = { margin: '0.5em'}, ...props }) { return {header}
{content} : content} multiline w={260} {...props}>
} 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 `` * beside a `