import React, {useContext, useEffect, useState} from "react"; import { AccordionContent, AccordionTitle, BreadcrumbSection, Button as SButton, ButtonGroup, Card, Container, Dimmer, DimmerDimmable, GridColumn, GridRow, Icon as SIcon, IconGroup, Input, Label, Pagination, Search, Transition } from 'semantic-ui-react'; import {Link, NavLink, useNavigate} from "react-router"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {useBluetooth, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useWROLMode} from "../hooks/customHooks"; import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts"; import { Accordion, Breadcrumb, Button, Card as ThemedCard, Confirm, darkTheme, Form, Header, Icon, lightTheme, Loader, Menu, Modal, Popup, Segment, Statistic } from "./Theme"; import {FilePreviewContext} from "./FilePreview"; import _ from "lodash"; import {killDownloads, startDownloads, unlockCookies} from "../api"; import {allFrequencyOptions, HELP_VIEWER_URI, NAME, semanticUIColorMap, validUrlRegex} from "./Vars"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; export function Paginator({activePage, onPageChange, totalPages, showFirstAndLast, size = 'mini'}) { const handlePageChange = (e, {activePage}) => { onPageChange(activePage); } return <> } 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 <>; } const trigger = {isoDatetimeToString(dt)}; return {elapsed} ago} on='hover' trigger={trigger} /> } 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; } export function CardLink({to, newTab = false, ...props}) { const {t} = useContext(ThemeContext); props = {...props, ...t}; props = newTab === true ? {...props, target: '_blank', rel: 'noopener noreferrer'} : props; return {props.children} } export function ExternalCardLink({to, children, ...props}) { const {t} = useContext(ThemeContext); return {children} } export function PreviewLink({file, children, className, ...props}) { const {t} = useContext(ThemeContext); const {setPreviewFile} = React.useContext(FilePreviewContext); className = className ? `clickable ${className}` : `clickable `; return setPreviewFile(file)} {...props} {...t}> {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', }); } const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small', placeholder = 'Search...', icon = 'search', clearDisabled = null, inputRef = null) => { const [value, setValue] = useState(searchStr || ''); const [submitted, setSubmitted] = useState(false); React.useEffect(() => { // Search was changed above. if (searchStr !== value) { setValue(searchStr); } if (!searchStr) { setValue(''); } }, [searchStr, value]); const handleChange = (e) => { if (e) { e.preventDefault(); } setValue(e.target.value); setSubmitted(false); if (onChange) { // Try to call the remote function, don't let its failure break this. try { onChange(e.target.value); } catch (e) { console.error(`Call to ${onChange} failed`); } } } const handleClearSearch = (e) => { e.preventDefault(); // Clear the input when the "clear" button is clicked, search again. setValue(''); setSubmitted(false); if (onSubmit) { onSubmit(''); } if (onClear) { onClear(); } } const localOnSubmit = (e) => { // Send the value up when submitting. e.preventDefault(); setSubmitted(true); if (onSubmit) { onSubmit(value); } else { console.debug('No onSubmit defined'); } } const clearButton = ; } 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 <> Cookies Locked You have encrypted cookies stored, but they are currently locked. setModalOpen(true)}> Unlock Cookies 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}) { return {links.map((link) => { const active = link.isActive ? link.isActive() : isActive; return active ? 'item active' : 'item'; }} > {link.text} )} } export function PageContainer(props) { return <> {props.children} {props.children} ; } export function CardGroupCentered(props) { return
{props.children} {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}) { const {s} = useContext(ThemeContext); // Used to center posters in CardIcon. const style = {display: 'flex', justifyContent: 'center', ...s['style']}; const navigate = useNavigate(); 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)}`; const image = <> {/* Replicate but with maxHeight applied to image */} {imageLabel} poster ; if (to) { // Link within this App. return {image} } else { // Preview the file. return
{image}
} } 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 }) { const trigger = ; return } export function InfoHeader({ icon, headerSize = 'h2', iconSize, headerContent, popupContent, popupPosition = null, popupProps = {}, for_ = null, required = false, ...props }) { return
} 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 <> setOpen(true)}/> setOpen(false)} size={modalSize} closeIcon> {title && {title}} } export function HelpHeader({ icon, headerSize = 'h2', iconSize, headerContent, helpPath, helpTitle, helpModalProps = {}, for_ = null, required = false, ...props }) { return
} export function HotspotToggle() { let {on, setHotspot} = useHotspot(); const disabled = on === null; return
setHotspot(checked)} /> {disabled && }
; } export function ThrottleToggle() { let {on, setThrottle} = useThrottle(); const disabled = on === null; return
setThrottle(checked)} /> {disabled && }
; } export function BluetoothToggle() { let {on, setBluetooth} = useBluetooth(); const disabled = on === null; return
setBluetooth(checked)} /> {disabled && }
; } export function Toggle({label, checked, disabled, onChange, icon, popupContent = null, info = null}) { // Custom toggle because Semantic UI does not handle inverted labels correctly. const {t, inverted} = useContext(ThemeContext); let style = {marginLeft: '1em'}; if (t && t.style) { style = {...t.style, ...style}; } disabled = disabled === true ? 'disabled' : ''; let inputClassName = `${disabled} ${inverted}`; let sliderClassName = `${disabled} ${inverted} slider`; if (inverted) { style['color'] = '#dddddd'; } if (disabled && inverted) { style['color'] = '#888888'; } let onMouseUp = () => { }; if (onChange) { onMouseUp = (e) => { if (disabled) { return; } e.preventDefault(); if (onChange) { onChange(!checked); } } } if (icon) { icon = } const body =
{icon} {label} {info && }
if (popupContent) { return } 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
; } 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 (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; 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 (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-openscad') || mimetype.startsWith('model/stl')) { return 'cube'; } 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 = 'huge', ...props}) { // Default to a grey file icon. const {mimetype, path, primary_path} = file; // `file` may be a file_group or a file. const lowerPath = primary_path ? primary_path.toLocaleString() : path.toLowerCase(); props['name'] = mimetypeIconName(mimetype, lowerPath); props['color'] = mimetypeColor(mimetype); return } export function LoadStatistic({label, value, cores, ...props}) { const quarter = cores / 4; if (cores && value >= (quarter * 3)) { props['color'] = 'red'; } else if (cores && value >= (quarter * 2)) { props['color'] = 'orange'; } return ; } export function DarkModeToggle() { const {savedTheme, cycleSavedTheme} = useContext(ThemeContext); let iconName = 'lightbulb outline'; if (savedTheme === darkTheme) { iconName = 'moon'; } else if (savedTheme === lightTheme) { iconName = 'sun'; } return } export function UnsupportedModal(header, message, icon) { const [open, setOpen] = useState(false); const onOpen = () => setOpen(true); const onClose = () => setOpen(false); const modal =
{header || 'Unsupported'}
{message}
; return {modal, doClose: onClose, doOpen: onOpen}; } 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 [disableHotspotOpen, setDisableHotspotOpen] = React.useState(false); const [inUseOpen, setInUseOpen] = React.useState(false); const handleConfirmDisable = (e) => { if (e) { e.preventDefault() } setDisableHotspotOpen(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 enable hotspot?` : 'Wifi device is in use. Disconnect and enable hotspot?' return <> setInUseOpen(false)} onClose={() => setInUseOpen(false)} onConfirm={handleConfirmInUse} header='Wifi is in-use' content={content} confirmButton='Enable Hotspot' /> setInUseOpen(true)}> } else if (dockerized === false && on === true) { return <> setDisableHotspotOpen(false)} onClose={() => setDisableHotspotOpen(false)} onConfirm={handleConfirmDisable} header='Disable the hotspot' content='You will be disconnected when using the hotspot. Are you sure?' confirmButton='Disable' /> setDisableHotspotOpen(true)}> {on === false && } {on === null && } } else if (dockerized === false && on === false) { return setHotspot(true)}> } // Hotspot is not available, or, status has not yet been fetched. return <> {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 = (e, data) => { if (e) { e.preventDefault(); } setDirectoryName(data.value); } const handleResultSelect = (e, data) => { if (e) { e.preventDefault(); } setDirectoryName(data.result.title); // title is the relative path. if (onSelect) { onSelect(data.result.title); } } const handleBlur = (e) => { // When user leaves the field, commit the typed value to the form if (onSelect && directoryName !== value) { onSelect(directoryName); } } return } export const BackButton = ({...props}) => { const navigate = useNavigate(); return }) } return <> setOpen(false)} > Sort By {sortFields} : {buttonContent}; // Wrap button in to show success or failure animations. button = {button} ; if (confirmContent) { // Wrap button with button = <> {button} setConfirmOpen(false)} onCancel={() => setConfirmOpen(false)} onConfirm={localOnConfirm} confirmButton={confirmButton} header={confirmHeader} /> } 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({}), } } export function InfoMessage({children, size = null, storageName = null, icon = 'info circle'}) { const {dismissed, setDismissed} = useMessageDismissal(storageName); if (dismissed) { return } return setDismissed(true) : undefined}> {children} } export function HandPointMessage({children, size = null, storageName = null}) { const {dismissed, setDismissed} = useMessageDismissal(storageName); if (dismissed) { return } return setDismissed(true) : undefined}> {children} } export function WarningMessage({children, size = null, icon = 'exclamation', storageName = null}) { const {dismissed, setDismissed} = useMessageDismissal(storageName); if (dismissed) { return } // Use color='yellow' because "warning" does not work. return setDismissed(true) : undefined}> {children} } export function ErrorMessage({children, size = null, icon = 'exclamation', storageName = null}) { const {dismissed, setDismissed} = useMessageDismissal(storageName); if (dismissed) { return } return setDismissed(true) : undefined}> {children} } 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
{text.split('\n').map((line, index, array) => index === array.length - 1 ? line :

{line}

)}
} 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); const {s} = React.useContext(ThemeContext); fallback = fallback ||
Frame could not load.
; 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: '#FFFFFF', }; // Allow provided `style` to overwrite. mobileStyle = style ? {...mobileStyle, ...style} : mobileStyle; let tabletStyle = {...mobileStyle, height: '93%'}; tabletStyle = style ? {...tabletStyle, ...style} : tabletStyle; const iframeMedia = <>