import React, {useContext, useEffect, useState} from "react"; import { AccordionContent, AccordionTitle, BreadcrumbSection, Button as SButton, ButtonGroup, Card, Confirm, Container, Dimmer, DimmerDimmable, Icon as SIcon, IconGroup, Input, Label, Pagination, Search, Transition } from 'semantic-ui-react'; import {Link, NavLink, useNavigate, useSearchParams} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import { useCPUTemperature, useHotspot, useLoad, useSearchDirectories, useSearchOrder, useThrottle, useWROLMode } from "../hooks/customHooks"; import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts"; import { Accordion, Breadcrumb, BreadcrumbDivider, Button, CardIcon, darkTheme, Form, Header, Icon, lightTheme, Loader, Menu, Modal, ModalActions, ModalContent, ModalDescription, ModalHeader, Popup, Segment, Statistic } from "./Theme"; import {FilePreviewContext} from "./FilePreview"; import _ from "lodash"; import {killDownloads, startDownloads} from "../api"; import {allFrequencyOptions, NAME, semanticUIColorMap, validUrlRegex} from "./Vars"; 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 RequiredAsterisk() { return * } export function secondsToFrequency(seconds) { const option = allFrequencyOptions[seconds]; return option ? option.text : null; } 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) { 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) => { 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 = ; 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 CPUTemperatureIcon({size = 'large', fallback = null}) { // Returns an Icon only if temperature is too high. const {temperature, highTemperature, criticalTemperature} = useCPUTemperature(); if (temperature === null || temperature < highTemperature) { // Temperature is not a problem. return fallback; } let icon = temperature >= criticalTemperature ? : ; icon = {icon}; return } export function SystemLoadIcon({size = 'large', fallback = null}) { const {minute_1, mediumLoad, highLoad} = useLoad(); if (!mediumLoad && !highLoad) { return fallback; } // Only return the icon if the load is not low. const color = highLoad ? 'red' : 'yellow'; let icon = ; return } 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); } } 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 function InfoMessage({children, size = null}) { return {children} } export function HandPointMessage({children, size = null}) { return {children} } export function WarningMessage({children, size = null, icon = 'exclamation'}) { // Use color='yellow' because "warning" does not work. return {children} } export function ErrorMessage({children, size = null, icon = 'exclamation'}) { return {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 = <>