import React, {useContext, useEffect, useState} from "react"; import {Card, Container, Image, Input, Pagination, Popup, Responsive, TableCell, TableRow} from 'semantic-ui-react'; import {Link, NavLink} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {useDownloaders, useHotspot, useThrottle} from "../hooks/customHooks"; import {getSettings} from "../api"; import {darkTheme, SettingsContext, ThemeContext} from "../contexts/contexts"; import {Button, Form, Header, Icon, Menu, Statistic} from "./Theme"; export const API_URI = `http://${window.location.host}/api`; export const VIDEOS_API = `${API_URI}/videos`; export const ARCHIVES_API = `${API_URI}/archive`; export const OTP_API = `${API_URI}/otp`; export const DEFAULT_LIMIT = 20; 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 secondsElapsed(seconds) { // 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; } let years; let days; let hours; let minutes; seconds = Math.abs((new Date().getTime() / 1000) - 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 (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` } export function secondsToElapsedPopup(seconds) { // Return a Popup which allows the user see a more detailed timestamp when hovering. const elapsed = secondsElapsed(seconds); if (!elapsed) { return <>; } return {elapsed}} /> } export function secondsToDuration(video) { let duration = video.duration; let hours = Math.floor(duration / 3600); duration -= hours * 3600; let minutes = Math.floor(duration / 60); let seconds = duration - (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({video}) { let [hours, minutes, seconds] = secondsToDuration(video); if (hours > 0) { return
{hours}:{minutes}:{seconds}
} else if (video.duration) { return
{minutes}:{seconds}
} else { return <> } } export function uploadDate(d) { // Convert a date integer to a human-readable date format. let upload_date = <>; if (d) { upload_date = new Date(d * 1000); upload_date = `${upload_date.getFullYear()}-${upload_date.getMonth() + 1}-${upload_date.getDate()}`; } return upload_date; } 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, ...props}) { const {t} = useContext(ThemeContext); return {props.children} } export function VideoCard({file}) { const {video} = file; const {s} = useContext(ThemeContext); let video_url = `/videos/video/${video.id}`; const upload_date = uploadDate(video.upload_date); // A video may not have a channel. const channel = video.channel ? video.channel : null; let channel_url = null; if (channel) { channel_url = `/videos/channel/${channel.id}/video`; video_url = `/videos/channel/${channel.id}/video/${video.id}`; } const poster_url = video.poster_path ? `/media/${encodeURIComponent(video.poster_path)}` : null; let imageLabel = null; if (video.favorite) { imageLabel = {corner: 'left', icon: 'heart', color: 'green'}; } let poster =
; if (poster_url) { poster = ; } return ( {poster}

{textEllipsis(video.title || video.stem || video.video_path, 100)}

{channel && {channel.name} }

{upload_date}

) } export function VideoRow({file, checkbox}) { const {video} = file; let video_url = `/videos/video/${video.id}`; const poster_url = video.poster_path ? `/media/${encodeURIComponent(video.poster_path)}` : null; let poster; let imageLabel = video.favorite ? {corner: 'left', icon: 'heart', color: 'green'} : null; if (poster_url) { poster = } else { poster = ; } return ( {checkbox}
{poster}
{textEllipsis(video.title || video.stem || video.video_path, 100)}
) } export function ArchiveRow({file, checkbox}) { const {archive} = file; const archiveUrl = `/archive/${archive.id}`; const posterUrl = archive.screenshot_path ? `/media/${encodeURIComponent(archive.screenshot_path)}` : null; let poster; if (posterUrl) { poster = ; } else { poster = ; } return ( {checkbox}
{poster}
{textEllipsis(archive.title || archive.stem, 100)}
) } export function RequiredAsterisk() { return * } export let defaultVideoOrder = '-upload_date'; export let defaultSearchOrder = 'rank'; export let videoOrders = [{key: '-upload_date', value: '-upload_date', text: 'Newest'}, { key: 'upload_date', value: 'upload_date', text: 'Oldest' }, {key: '-duration', value: '-duration', text: 'Longest'}, { key: 'duration', value: 'duration', text: 'Shortest' }, {key: '-viewed', value: '-viewed', text: 'Recently viewed'}, { key: '-size', value: '-size', text: 'Largest' }, {key: 'size', value: 'size', text: 'Smallest'}, { key: '-view_count', value: '-view_count', text: 'Most Views' }, {key: 'view_count', value: 'view_count', text: 'Least Views'}, { key: '-modification_datetime', value: '-modification_datetime', text: 'Newest File' }, {key: 'modification_datetime', value: 'modification_datetime', text: 'Oldest File'},] export let searchOrders = [{key: 'rank', value: 'rank', text: 'Search Rank', title: 'Search Results'},]; export const frequencyOptions = [{key: 'daily', text: 'Daily', value: 86400}, { key: 'weekly', text: 'Weekly', value: 604800 }, {key: 'biweekly', text: 'Biweekly', value: 1209600}, { key: '30days', text: '30 Days', value: 2592000 }, {key: '90days', text: '90 Days', value: 7776000},]; export const rssFrequencyOptions = [{key: 'once', text: 'Once', value: 0}, { key: 'hourly', text: 'Hourly', value: 3600 }, {key: '3hours', text: '3 hours', value: 10800}, {key: '12hours', text: '12 hours', value: 43200}, { key: 'daily', text: 'Daily', value: 86400 }, {key: 'weekly', text: 'Weekly', value: 604800}, {key: 'biweekly', text: 'Biweekly', value: 1209600}, { key: '30days', text: '30 Days', value: 2592000 }, {key: '90days', text: '90 Days', value: 7776000},]; export function secondsToFrequency(seconds) { for (let i = 0; i < Object.keys(rssFrequencyOptions).length; i++) { let d = rssFrequencyOptions[i]; if (d.value === seconds) { return d.text; } } return null; } const secondsToYears = 31536000; const secondsToDays = 86400; const secondsToHours = 3600; const secondsToMinutes = 60; export function secondsToFullDuration(seconds) { let s = ''; 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) { s = `${years}Y`; } if (days > 0) { s = `${s} ${days}D`; } if (hours > 0 || minutes > 0) { hours = String(hours).padStart(2, '0'); minutes = String(minutes).padStart(2, '0'); s = `${s} ${hours}:${minutes}`; } return s; } export function secondsToDate(seconds) { let date = new Date(seconds * 1000); return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; } export function secondsToTimestamp(seconds) { let d = new Date(seconds * 1000); 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()}-${d.getMonth() + 1}-${day} ${hours}:${minutes}:${sec}`; } export function humanFileSize(bytes, si = false, 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 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 class APIForm extends React.Component { constructor(props) { super(props); this.state = { dirty: false, disabled: false, error: false, loading: false, message_content: '', message_header: '', success: false, } } setError = (header, content, errorName) => { let errors = {...this.state.errors}; if (errorName) { errors[errorName] = true; } this.setState({ error: true, errors: errors, message_content: content, message_header: header, success: false, }); } setSuccess = (header, content) => { this.setState({ error: false, message_content: content, message_header: header, success: true, }); } initFormValues = (original) => { // Set the "original" values in state to the provided values. This will be used to compare against any form // changes. let inputs = {}; let errors = {}; let inputKeys = Object.keys(this.state.inputs); for (let i = 0; i < inputKeys.length; i++) { let key = inputKeys[i]; inputs[key] = original[key] || ''; errors[key] = false; } this.setState({original: {...original}, inputs: inputs, errors: errors}); } isDirty = () => { // Compare dictionaries "inputs" and "original". "original" should never change once it is set. let inputKeys = Object.keys(this.state.inputs); for (let i = 0; i < inputKeys.length; i++) { let name = inputKeys[i]; if (!this.state.original && this.state.inputs[name]) { // Form has changed from it's empty value. return true; } else if (this.state.original && this.state.original[name] !== this.state.inputs[name]) { // Form has changed from it's initial value. return true; } } return false; } checkDirty = () => { this.setState({dirty: this.isDirty()}) } getEmptyErrors = () => { let errors = {}; let inputKeys = Object.keys(this.state.inputs); for (let i = 0; i < inputKeys.length; i++) { let key = inputKeys[i]; errors[key] = false; } return errors; } setLoading = () => { this.setState({ disabled: true, error: false, errors: this.getEmptyErrors(), loading: true, success: false, }) } clearLoading = () => { this.setState({ disabled: false, loading: false, }) } handleInputChange = async (event, {name, value}) => { let inputs = this.state.inputs; inputs[name] = value; this.setState({inputs: inputs}, this.checkDirty); } handleCheckbox = async (checkbox) => { let checked = checkbox.current.state.checked; let name = checkbox.current.props.name; let inputs = this.state.inputs; inputs[name] = !checked; this.setState({inputs: inputs}, this.checkDirty); } } 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 changePageHistory(history, location, activePage, searchStr, searchOrder, filters) { let search = `?page=${activePage}`; if (searchStr) { search = `${search}&q=${searchStr}`; } if (searchOrder) { search = `${search}&o=${searchOrder}`; } if (filters.length > 0) { filters = filters.join(','); search = `${search}&f=${filters}`; } history.push({ pathname: location.pathname, search: search, }); scrollToTop(); } export function SearchInput({ searchStr, onSubmit, size = 'small', placeholder, action, actionIcon, clearable, ...props }) { let [value, setValue] = useState(searchStr || ''); const handleClearSearch = (e) => { e.preventDefault(); // Clear the input when the "clear" button is clicked, search again. setValue(''); onSubmit(''); } const localOnSubmit = (e) => { // Send the value up when submitting. e.preventDefault(); onSubmit(value); } if ((action || actionIcon) && searchStr && searchStr === value && clearable) { action = ; } return
setValue(e.target.value)} value={value} size={size} action={action} />
} export const useWROLMode = () => { const [enabled, setEnabled] = useState(false); const fetchStatus = async () => { try { const settings = await getSettings(); if (settings) { setEnabled(settings.wrol_mode); } } catch (e) { console.error(e); } } useEffect(() => { fetchStatus(); }, []); return enabled; } export function WROLModeMessage({content}) { const settings = useContext(SettingsContext); const wrol_mode = settings ? settings.wrol_mode : null; if (wrol_mode) { return } return null; } // 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) => {link.text} )} ) } export function PageContainer(props) { return (<> {props.children} {props.children} ) } export function CardGroupCentered(props) { return (
{props.children} {props.children}
) } export function HelpPopup({icon, size, content, position}) { return } /> } export function HelpHeader({icon, headerSize, iconSize, headerContent, popupContent}) { return (
{headerContent}
) } export function HotspotToggle() { let {on, setHotspot} = useHotspot(); const disabled = on === null; return (
setHotspot(data.checked)} /> {disabled && }
); } export function ThrottleToggle() { let {on, setThrottle} = useThrottle(); const disabled = on === null; return (
setThrottle(data.checked)} /> {disabled && }
); } export function Toggle({label, checked, disabled, onChange, icon}) { // Custom toggle because Semantic UI does not handle inverted labels correctly. const {i, t} = useContext(ThemeContext); const inverted = i && i['inverted'] === true ? 'inverted' : ''; 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 = } return <>
{icon} {label} } export function DisableDownloadsToggle() { let {on, setDownloads} = useDownloaders(); 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) { try { if (mimetype === 'application/pdf') { return 'red' } else if (mimetype && mimetype.startsWith('video/')) { return 'blue' } else if (mimetype && mimetype.startsWith('image/')) { return 'pink' } else if (mimetype && mimetype.startsWith('text/html')) { return 'green' } else if (mimetype && mimetype.startsWith('application/zip')) { return 'purple' } } catch (e) { console.error(e); console.error('Unable to choose mimetype color'); } return 'grey' } export function FileIcon({file, disabled = true, size = 'huge', ...props}) { // Default to a grey file icon. const {mimetype} = file; props['name'] = 'file'; props['color'] = mimetypeColor(mimetype); if (mimetype) { if (mimetype.startsWith('text/html') || mimetype.startsWith('application/json')) { props['name'] = 'file code'; } else if (mimetype.startsWith('application/pdf')) { props['name'] = 'file pdf'; } else if (mimetype.startsWith('text/plain')) { props['name'] = 'file text'; } else if (mimetype.startsWith('image/')) { props['name'] = 'image'; } else if (mimetype.startsWith('video/')) { props['name'] = 'film'; } else if (mimetype.startsWith('application/zip')) { props['name'] = 'file archive'; } else if (mimetype.startsWith('application/x-iso9660-image')) { props['name'] = 'dot circle'; } } return } export function LoadStatistic({label, value, cores, ...props}) { let color; const quarter = cores / 4; if (cores && value >= (quarter * 3)) { color = 'red'; } else if (cores && value >= (quarter * 2)) { color = 'orange'; } return ; } export function DarkModeToggle() { const {theme, setDarkTheme, setLightTheme} = useContext(ThemeContext); const darkMode = theme === darkTheme; const toggleDarkMode = () => { if (darkMode) { setLightTheme(); } else { setDarkTheme(); } } return <> } export function useTitle(title) { const documentDefined = typeof document !== 'undefined'; const originalTitle = React.useRef(documentDefined ? document.title : null); useEffect(() => { if (!documentDefined) { return; } const newTitle = `${title} - WROLPi` if (title && document.title !== newTitle) { document.title = newTitle; } return () => { document.title = originalTitle.current; } }, []); }