From e8dd804010e795e402e14610d69aad7b859dc35f Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 12:21:30 -0600 Subject: [PATCH] ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have. --- app/src/App.js | 24 +- app/src/DashboardPage.js | 153 +- app/src/DashboardPage.test.js | 33 +- app/src/Events.js | 2 +- app/src/Tags.js | 235 ++-- app/src/api.js | 2 +- app/src/components/Archive.js | 141 +- app/src/components/DatesSelector.js | 153 +- app/src/components/Download.js | 1230 +++++++---------- app/src/components/Files.js | 468 +++---- app/src/components/HelpModal.js | 41 +- app/src/components/Map.js | 188 ++- app/src/components/MapSearchView.js | 64 +- app/src/components/MapViewer.js | 68 +- app/src/components/Search.js | 73 +- app/src/components/SearchFilterModal.test.js | 34 +- app/src/components/Videos.js | 572 ++++---- app/src/components/admin/Settings.js | 625 ++++----- app/src/components/admin/Status.js | 235 ++-- .../components/calculators/DriveCalculator.js | 228 +-- .../calculators/ElectricalCalculators.js | 295 ++-- .../calculators/FoodStorageCalculator.js | 111 +- .../components/calculators/HamCalculators.js | 121 +- .../calculators/OneTimePadCalculator.js | 124 +- .../components/calculators/RatioCalculator.js | 186 +-- .../calculators/RationCalculator.js | 136 +- .../calculators/RationCalculator.test.js | 6 +- .../components/calculators/WaterCalculator.js | 170 ++- .../collections/BatchReorganizeModal.js | 241 ++-- .../collections/BatchReorganizeModal.test.js | 2 +- .../collections/CollectionReorganizeModal.js | 239 ++-- .../CollectionReorganizeModal.test.js | 2 +- .../components/collections/CollectionTable.js | 64 +- .../collections/CollectionTable.test.js | 41 +- .../collections/ConflictResolutionModal.js | 260 ++-- app/src/components/inventory/FieldCell.js | 78 +- .../components/inventory/FieldSchemaEditor.js | 115 +- .../inventory/FieldSchemaEditor.test.js | 2 +- .../inventory/InventoryImportModal.js | 92 +- .../inventory/InventoryImportModal.test.js | 3 +- .../components/inventory/InventoryRoute.js | 118 +- .../components/inventory/InventoryTable.js | 74 +- .../inventory/InventoryTable.test.js | 10 +- app/src/components/ui/Inputs.tsx | 15 +- app/src/components/ui/Surfaces.tsx | 16 +- app/src/hooks/customHooks.js | 2 +- app/src/hooks/useForm.js | 21 +- app/src/semantic-baseline.json | 37 +- 48 files changed, 3273 insertions(+), 3877 deletions(-) diff --git a/app/src/App.js b/app/src/App.js index 425a3a45..68d69f8a 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -4,7 +4,7 @@ import {NavBar} from "./components/Nav"; import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, RouterProvider} from "react-router"; import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos"; import AdminRoute from "./components/admin/AdminRoute"; -import {Container} from "semantic-ui-react"; +import {Box, Header} from "./components/ui"; import 'semantic-ui-offline/semantic.min.css'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; @@ -20,13 +20,12 @@ import {QueryProvider, StatusProvider} from "./hooks/customHooks"; import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext"; import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels"; import {MapRoute} from "./components/Map"; -import {MediaContextProvider, mediaStyles, StatusContext, ThemeContext} from "./contexts/contexts"; -import {Header, ThemeProvider} from "./components/Theme"; +import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts"; +import {ThemeProvider} from "./components/Theme"; import {DashboardPage} from "./DashboardPage"; import {DonatePage} from "./components/DonatePage"; import {ThemeSamplePage} from "./components/ThemeSamplePage"; import {useEventsInterval} from "./Events"; -import {SemanticToastContainer} from "react-semantic-toasts-2"; import {FilePreviewProvider} from "./components/FilePreview"; import {TagsProvider} from "./Tags"; import {ZimRoute} from "./components/Zim"; @@ -37,11 +36,10 @@ import ErrorBoundary from "./components/ErrorBoundary"; import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider"; function PageNotFound() { - const {t} = useContext(ThemeContext); - return + return
Page Not Found!
-

The page you requested cannot be found

-
+

The page you requested cannot be found

+ } function Dot() { @@ -49,7 +47,6 @@ function Dot() { } function Footer() { - const {t} = useContext(ThemeContext); const {status} = useContext(StatusContext); let version; try { @@ -57,13 +54,13 @@ function Footer() { } catch (e) { // Not logging because this is not that important. } - return - + return + WROLPi {version} WROLPi.org Donate - + } function Root() { @@ -129,8 +126,7 @@ export default function App() { {/* Context and style to handle switching between mobile/computer. */} - {/* Toasts can be on any page. */} - + {/* Toasts (from components/ui) are mounted by ThemeProvider, which wraps every page. */} diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index 440b4d31..82902bc6 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -12,19 +12,22 @@ import {useHotkeys} from "react-hotkeys-hook"; import {Media, SettingsContext, StatusContext} from "./contexts/contexts"; import {DownloadMenu} from "./components/Download"; import { + ActionInput, Button, Divider, - Form, + Group, Header, + Icon, + Message, Modal, - Segment, - Statistic -} from "./components/Theme"; + Panel, + Stack, + Statistic, + StatisticGroup, +} from "./components/ui"; import {Link, useNavigate, useSearchParams} from "react-router"; import {BandwidthProgressCombined, CPUUsageProgress} from "./components/admin/Status"; import {ProgressPlaceholder} from "./components/Placeholder"; -import {GridColumn, GridRow, Icon, Input, Message} from "semantic-ui-react"; -import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import {refreshFiles} from "./api"; import _ from "lodash"; import {TagsDashboard} from "./Tags"; @@ -37,6 +40,9 @@ import {fileMimetypeFilterOptions, FileCards, SearchFilterButton} from "./compon import {useCalculators} from "./components/Calculators"; import {classifyAndEvaluate} from "./components/calculators/mathConfig"; +// A spinning refresh glyph, used as the `icon` for the "files are being refreshed" Message. +const RefreshingIcon = (props) => ; + export function FlagsMessages() { const {settings, fetchSettings} = React.useContext(SettingsContext); const {status} = useContext(StatusContext); @@ -53,47 +59,44 @@ export function FlagsMessages() { // Do not tell the maintainer to refresh the files if the FileWorker is busy. if (flags.file_worker_busy) { // FileWorker is busy. - refreshingMessage = - - - Your files are being refreshed. -

Click here to view the progress

-
+ refreshingMessage = +

Click here to view the progress

; } else if (!flags.refresh_complete) { // `refresh_complete` flag is not set. Tell the maintainer to refresh the files. - refreshRequiredMessage = - - - Refresh required - Click here to refresh all your files. - + refreshRequiredMessage = + { + e.preventDefault(); + refreshFiles(); + }}>Click here to refresh all your files. ; } let dbDownMessage; if (!flags.db_up) { dbDownMessage = - Database is down - API is unable to connect to the database. Check the server logs. + Database is down +

API is unable to connect to the database. Check the server logs.

} let internetDownMessage; if (!flags.have_internet && !settings.wrol_mode) { internetDownMessage = - No Internet - WROLPi has no Internet. Downloads will not start. + No Internet +

WROLPi has no Internet. Downloads will not start.

; } let mediaUnmountedMessage; if (flags.media_mounted === false) { - mediaUnmountedMessage = - No drive mounted - WROLPi has no media drive mounted for one or more storage locations. - Downloads would write to the root filesystem and can fill it. - {' '}Open the Controller to mount a drive. + mediaUnmountedMessage = + No drive mounted +

+ WROLPi has no media drive mounted for one or more storage locations. + Downloads would write to the root filesystem and can fill it. + {' '}Open the Controller to mount a drive. +

; } @@ -140,7 +143,6 @@ function EvaluateForm() {
2 Tb to Gb
- const inputRef = React.useRef(); const [showMessage, setShowMessage] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); const [evaluatedValue, setEvaluatedValue] = React.useState(helpContents); @@ -166,45 +168,36 @@ function EvaluateForm() { } } - return
- setShowMessage(true)} - onChange={(e, {value}) => setInputValue(value)} - action={} + return { + e.preventDefault(); + doEvaluate(); + }}> + setShowMessage(true)} + onChange={(e) => setInputValue(e.currentTarget.value)} + action={} /> {showMessage && - - Result - - {evaluatedValue} - + + {evaluatedValue} } - + } function DashboardCalculators() { const {calculatorLinks} = useCalculators(); - return + return
Calculators
- - - - - - - - - {calculatorLinks} - - - -
+ + +
{calculatorLinks}
+
+ } export function Getters() { @@ -244,25 +237,17 @@ export function Getters() { } } - const getter = - - Or - - - - - - - - - - ; + const getter = + + + + + + ; let getterModal; if (selectedGetter === 'downloads') { @@ -330,16 +315,16 @@ function DashboardStatus() { bandwidths = null; } - return + return
Status
- + - +
Bandwidth
{bandwidths} @@ -348,25 +333,25 @@ function DashboardStatus() { - + - + -
; + ; } function DashboardRecentFiles() { const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(); - return + return - + } export function DashboardPage() { diff --git a/app/src/DashboardPage.test.js b/app/src/DashboardPage.test.js index c86d2021..20f71055 100644 --- a/app/src/DashboardPage.test.js +++ b/app/src/DashboardPage.test.js @@ -1,8 +1,7 @@ import React from 'react'; -import {render, screen} from '@testing-library/react'; -import {MemoryRouter} from 'react-router'; +import {render, screen} from './test-utils'; import {Getters, FlagsMessages} from './DashboardPage'; -import {SettingsContext, StatusContext, ThemeContext} from './contexts/contexts'; +import {SettingsContext, StatusContext} from './contexts/contexts'; // Replace DownloadMenu with a stub that records its props. The deep-link // behavior under test is just "Getters reads URL params and forwards them @@ -23,18 +22,14 @@ jest.mock('./hooks/customHooks', () => ({ })); function renderAtUrl(url) { + // test-utils' render() wraps content in a BrowserRouter (not MemoryRouter), + // so point the jsdom location at the desired URL before rendering. + window.history.pushState({}, '', url); const status = {flags: {refresh_complete: true}}; - const theme = { - i: {}, s: {}, t: {}, theme: 'light', inverted: false, setInverted: () => {}, - }; return render( - - - - - - - + + + ); } @@ -80,13 +75,11 @@ describe('DashboardPage Getters deep-link', () => { function renderFlagsMessages({flags = {}, settings = {}} = {}) { const status = {flags: {refresh_complete: true, db_up: true, have_internet: true, ...flags}}; return render( - - {}}}> - - - - - + {}}}> + + + + ); } diff --git a/app/src/Events.js b/app/src/Events.js index be4b6795..3d21622b 100644 --- a/app/src/Events.js +++ b/app/src/Events.js @@ -1,7 +1,7 @@ import React, {useEffect} from 'react'; import {useRecurringTimeout} from "./hooks/customHooks"; import {ApiDownError, getEvents} from "./api"; -import {toast} from "react-semantic-toasts-2"; +import {toast} from "./components/ui"; const apiEventName = 'apiEvent'; diff --git a/app/src/Tags.js b/app/src/Tags.js index ce2f42f2..585feac9 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -1,6 +1,5 @@ import React, {useEffect} from "react"; import {ApiDownError, deleteTag, getRecentTags, getTags, saveTag} from "./api"; -import {Dimmer, Divider, Grid, GridColumn, GridRow, Input, Label, TableCell, TableRow,} from "semantic-ui-react"; import { APIButton, contrastingColor, @@ -10,17 +9,24 @@ import { scrollToTopOfElement } from "./components/Common"; import { + Box, Button, - Form, + Divider, + Group, Header, - Loader, + Icon, + IconButton, + Label, + Loading, Modal, - Segment -} from "./components/Theme"; + Panel, + Table, + TextInput, +} from "./components/ui"; import _ from "lodash"; import {HexColorPicker} from "react-colorful"; import {useRecurringTimeout} from "./hooks/customHooks"; -import {Media, QueryContext, ThemeContext} from "./contexts/contexts"; +import {Media, QueryContext} from "./contexts/contexts"; import {Link, useNavigate} from "react-router"; import {TagPlaceholder} from "./components/Placeholder"; import {SortableTable} from "./components/SortableTable"; @@ -96,33 +102,36 @@ export function useTags() { || fuzzyMatch(lowerName, i.name.toLowerCase(), 2)) } + // A single tag chip. Tag colors are chosen by the user (stored per-tag), not by the + // theme, so they are rendered with the raw hex value rather than a token -- but the + // `wrolpi-label` className still gives them the theme's shape (and its night-mode + // outline treatment, which keys off the same `--label-color` variable). const NameToTagLabel = ({name, to, ...props}) => { const tag = findTagByName(name); if (tag !== null && tag !== undefined) { const tagColor = tag['color'] || DEFAULT_TAG_COLOR; const textColor = contrastingColor(tagColor); - const style = {...props['style'], backgroundColor: tagColor, color: textColor}; - return ; + ; } // No tags have been fetched. - return ; + return ; } const TagsGroup = ({tagNames, onClick}) => { if (!tagNames || tagNames.length === 0) { return ; } - return + return {tagNames.map(i => onClick(i)}/>)} - + } const TagLabelLink = ({name, props}) => { @@ -147,13 +156,13 @@ export function useTags() { return ; } - return + return {tagNames.map(i => )} - + } const SingleTag = ({name, ...props}) => { - return + return } useEffect(() => { @@ -187,16 +196,15 @@ function EditTagRow({tag, onDelete, onEdit}) { const {SingleTag} = React.useContext(TagsContext); const {name, color, id, file_group_count, zim_entry_count, channel_count, domain_count} = tag; - const deleteConfirm = <> - onDelete(id, name)} - /> - ; - const editButton = - - - Save - - - + + + Save + } @@ -378,7 +368,6 @@ function EditTagsModal() { } @@ -388,7 +377,7 @@ function EditTagsModal() { - @@ -406,7 +395,6 @@ export function AddTagsButton({ limit = null, disabled = false, filterByOverlap = false, - color = 'violet', }) { // A button which displays a modal in which the user can add or remove tags. @@ -556,19 +544,17 @@ export function AddTagsButton({ const hideFrequentTags = filterByOverlap && localTags && localTags.length > 0; return <> - - {showAny && } - - - - + + + {!hideEdit && } + + + + {showAny && } + + + @@ -663,43 +646,37 @@ export const TagsSelector = ({ return button; } - return - - - {button} - - - - - - + return + {button} + + + + } export const TagsDashboard = () => { const {tagNames, TagsLinkGroup} = React.useContext(TagsContext); - const tagPlaceholder = ; - let availableTagsGroup = - - {tagPlaceholder} - {tagPlaceholder} - {tagPlaceholder} - - ; + const tagPlaceholder = ; + let availableTagsGroup = + {tagPlaceholder} + {tagPlaceholder} + {tagPlaceholder} + ; if (tagNames && tagNames.length >= 1) { availableTagsGroup = ; } else if (tagNames === undefined) { availableTagsGroup = Could not fetch tags } - return + return
Tags
{availableTagsGroup} -
+ } export const TagsProvider = (props) => { diff --git a/app/src/api.js b/app/src/api.js index 3d8a3e4b..a55d44a8 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1,5 +1,5 @@ import {emptyToNull} from "./components/Common"; -import {toast} from "react-semantic-toasts-2"; +import {toast} from "./components/ui"; import _ from "lodash"; import { API_URI, diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index 6824fff4..8893b919 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -237,28 +237,12 @@ function ArchivePage() { ? isoDatetimeToAgoPopup(archiveFile.published_modified_datetime, true) : 'unknown'; - const aboutPane = { - menuItem: 'About', render: () => -
Domain
- {domainHeader} - -
Size
- {humanFileSize(size)} - -
URL
-

{archiveFile.url ? {archiveFile.url} : 'N/A'}

- -
Modified Date
-

{modifiedDatetimeString}

-
- }; - // Helper to find file size from the files array by resolved path const findFileSize = (resolvedPath) => { if (!resolvedPath || !archiveFile.files) return null; const file = archiveFile.files.find(i => String(i.path) === String(resolvedPath)); return file && file.size - ? ({humanFileSize(file.size)}) + ? ({humanFileSize(file.size)}) : null; }; @@ -275,51 +259,11 @@ function ArchivePage() { const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'}; const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'}; - const filesPane = { - menuItem: 'Files', render: () => - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Singlefile File{localPreviewPath(data.singlefile_path, 'text/html')}
Readability File{localPreviewPath(data.readability_path, 'text/html')}
Readability Text File{localPreviewPath(data.readability_txt_path, 'text/plain')}
Readability JSON File{localPreviewPath(data.readability_json_path, 'application/json')}
Screenshot File - {screenshotPath - ? <>{screenshotPath}{findFileSize(screenshotPath)} - : 'Unknown'} -
Directory
-
- }; - - const tabPanes = [aboutPane, filesPane]; - const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true}; return <> - + {screenshot}
{textEllipsis(archiveFile.title || data.url)}
@@ -327,15 +271,13 @@ function ArchivePage() {
Author: {archiveFile.author ? archiveFile.author : 'unknown'}
- - - -
Published: {publishedDatetimeString}
-
- -
Downloaded: {downloadDatetimeString}
-
-
+ + +
Published: {publishedDatetimeString}
+
+ +
Downloaded: {downloadDatetimeString}
+
{singlefileButton} @@ -344,25 +286,78 @@ function ArchivePage() { {deleteButton} {generateScreenshotButton} -
+ - + - + - + + + About + Files + + +
Domain
+ {domainHeader} - +
Size
+ {humanFileSize(size)} + +
URL
+

{archiveFile.url ? {archiveFile.url} : 'N/A'}

+ +
Modified Date
+

{modifiedDatetimeString}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Singlefile File{localPreviewPath(data.singlefile_path, 'text/html')}
Readability File{localPreviewPath(data.readability_path, 'text/html')}
Readability Text File{localPreviewPath(data.readability_txt_path, 'text/plain')}
Readability JSON File{localPreviewPath(data.readability_json_path, 'application/json')}
Screenshot File + {screenshotPath + ? <>{screenshotPath}{findFileSize(screenshotPath)} + : 'Unknown'} +
Directory
+
+
+ + {historyList} - + { return dateRange[0] === null && dateRange[1] === null; @@ -39,9 +36,10 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) { return { + size='lg' + onChange={(e) => { // Add or remove month index if the respective checkbox is checked. + const checked = e.currentTarget.checked; if (checked) { setMonthsSelected([...monthsSelected, idx]); } else { @@ -51,36 +49,37 @@ export function MonthsForm({monthsSelected, setMonthsSelected}) { /> } - return
- - - {monthCheckbox('January')} - {monthCheckbox('February')} - {monthCheckbox('March')} - - - {monthCheckbox('April')} - {monthCheckbox('May')} - {monthCheckbox('June')} - - - {monthCheckbox('July')} - {monthCheckbox('August')} - {monthCheckbox('September')} - - - {monthCheckbox('October')} - {monthCheckbox('November')} - {monthCheckbox('December')} - - - - - - - - -
+ const monthRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.7em', marginBottom: '0.7em'}; + const seasonRowStyle = {display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '0.7em'}; + + return
+
+
{monthCheckbox('January')}
+
{monthCheckbox('February')}
+
{monthCheckbox('March')}
+
+
+
{monthCheckbox('April')}
+
{monthCheckbox('May')}
+
{monthCheckbox('June')}
+
+
+
{monthCheckbox('July')}
+
{monthCheckbox('August')}
+
{monthCheckbox('September')}
+
+
+
{monthCheckbox('October')}
+
{monthCheckbox('November')}
+
{monthCheckbox('December')}
+
+
+ + + + +
+
} export function DateRangeForm({dateRange, setDateRange}) { @@ -89,20 +88,16 @@ export function DateRangeForm({dateRange, setDateRange}) { let yearRange = []; for (let i = 1970; i <= currentYear; i++) { - yearRange = [...yearRange, { - key: i, - text: i, - value: i, - }]; + yearRange = [...yearRange, {value: i.toString(), label: i.toString()}]; } - const handleFromYear = (e, {value}) => { + const handleFromYear = (value) => { const toYear = dateRange[1] || currentYear; - setDateRange([value, toYear]); + setDateRange([value ? parseInt(value) : null, toYear]); } - const handleToYear = (e, {value}) => { - setDateRange([dateRange[0], value]); + const handleToYear = (value) => { + setDateRange([dateRange[0], value ? parseInt(value) : null]); } React.useEffect(() => { @@ -113,37 +108,33 @@ export function DateRangeForm({dateRange, setDateRange}) { } }, [dateRange]) - const errorMessage = error && ; + const errorMessage = error && {error}; return -
- - - - - - - - - - - - - - - - -
+ + + + + + {errorMessage}
} @@ -221,7 +212,7 @@ export function DateSelectorButton({ return - + + diff --git a/app/src/components/Download.js b/app/src/components/Download.js index a7af9306..d854348b 100644 --- a/app/src/components/Download.js +++ b/app/src/components/Download.js @@ -7,23 +7,18 @@ import { InfoPopup, mergeDeep, RequiredAsterisk, - useLocalStorage + SimpleAccordion, + Toggle, + useLocalStorage, + validURL, + validURLs, } from "./Common"; -import Icon from "semantic-ui-react/dist/commonjs/elements/Icon"; -import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {Accordion, Button, Form, Header} from "./Theme"; -import {Form as SForm, FormDropdown} from "semantic-ui-react"; +import {Button, Group, Header, Icon, Message, MultiSelect, NumberInput, Select, Stack, TextInput, Textarea} from "./ui"; import {Link} from "react-router"; import {TagsSelector} from "../Tags"; -import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import { commaSeparatedValidator, - InputForm, - NumberInputForm, - ToggleForm, - UrlInput, - UrlsTextarea, - useForm + useForm, } from "../hooks/useForm"; import { channelFrequencyOptions, @@ -41,8 +36,134 @@ import { weeklyOption } from "./Vars"; +/* + * Download.js was built around Semantic UI's
family (Form.Input, + * FormDropdown, TextArea) via `hooks/useForm.js`'s JSX helpers (InputForm, + * NumberInputForm, ToggleForm, UrlInput, UrlsTextarea). `hooks/useForm.js` is + * off-limits for this migration and its JSX helpers still render through + * `./Theme` (Semantic UI). Rather than use those helpers, the field + * components below call `useForm`'s plain data getters (getInputProps, + * getSelectionProps, getCustomProps) directly and render our own markup with + * the token-driven `./ui` library. See the migration report for what this + * leaves out of scope (hooks/useForm.js itself is unmigrated). + */ + +function FieldLabel({htmlFor, label, required, helpContent, helpPosition = 'top'}) { + if (!label) return null; + return +} + +/** Text field backed by `form.getInputProps`. TextInput passes a DOM event, which is exactly + * what `handleInputEvent` (inside useForm) expects, so the props can be spread through. */ +function TextField({ + form, name, path, type = 'text', validator, required = false, + placeholder = '', label, helpContent, helpPosition, disabled = false, + }) { + const [inputProps] = form.getInputProps({name, path, validator, type, required}); + return
+ + +
+} + +/** Number field. Mantine's NumberInput hands back the value itself, not an event, so it is + * wrapped into the event shape `handleInputEvent` expects. */ +function NumberField({ + form, name, path, validator, required = false, placeholder = '', + label, helpContent, helpPosition, min = 1, max = 9999999999, + }) { + validator = validator || ((value) => { + value = typeof value === 'number' ? value : parseFloat(value); + if (value < 0) { + return 'Number must be positive'; + } + }); + const [inputProps] = form.getInputProps({name, path, validator, type: 'number', required}); + const dataPath = inputProps['data-path']; + return
+ + inputProps.onChange({target: {value, type: 'number', dataset: {path: dataPath}}})} + disabled={inputProps.disabled} + error={inputProps.error} + /> +
+} + +/** A single-select field. `options` are Semantic-shaped ({key, text, value}); Mantine's Select + * requires string data values, so values are stringified for display and mapped back on change. */ +function SelectField({form, name, path, label, options, required = false, placeholder, disabled = false, afterChange}) { + const [inputProps] = form.getSelectionProps({name, path, required, afterChange}); + const data = options.map(o => ({value: String(o.value), label: o.text})); + const stringValue = (inputProps.value === null || inputProps.value === undefined) ? null : String(inputProps.value); + + const handleChange = (val) => { + if (val === null || val === undefined) { + inputProps.onChange(null, {value: null}); + return; + } + const match = options.find(o => String(o.value) === val); + inputProps.onChange(null, {value: match ? match.value : val}); + }; + + return
+ + - - + return } export function TitleExclusionInput({form, path = 'settings.title_exclude'}) { - const [inputProps, inputAttrs] = form.getInputProps({ - name: 'title_exclude', - path, - validator: commaSeparatedValidator, - }); - - return <> - - - - - + return } export function DownloadFormButtons({onCancel, form}) { - return - - - + Download + } export function EditDownloadFormButtons({onDelete, onCancel, form}) { - return - - - Delete - - - + Save + + } function VideoDownloadOrder({form, path = 'settings.download_order'}) { - const [inputProps, inputAttrs] = form.getSelectionProps({ - name: 'download_order', - path, - }); - - return } function VideoDownloadCountLimit({form, name = 'video_count_limit', path = 'settings.video_count_limit'}) { - return + return
- - +
} export function VideoFormatSelectorForm({form, name = 'video_format', path = 'settings.video_format'}) { - const [inputProps, inputAttrs] = form.getSelectionProps({ - name, - path, - defaultValue: defaultVideoFormatOption, - }); - - return <> + return
- - + +
} export function AudioOnlyToggle({form, onChange}) { - return { + inputProps.onChange(value); + if (onChange) onChange(value); + }} + info='Download only the audio track from videos.' /> } export function AudioFormatSelectorForm({form, name = 'audio_format', path = 'settings.audio_format'}) { - const [inputProps, inputAttrs] = form.getSelectionProps({ - name, - path, - defaultValue: defaultAudioFormatOption, - }); - - return <> + return
- - + +
} function VideoDurationLimit({form, name, path, label, helpContent, placeholder, helpPosition}) { - return + return
- +
} export function ChannelTagNameForm({form}) { - return <> + return
- +
} function AdvancedVideoSettings({form, isVideoLevel = false, isConfigLoaded = true}) { - const [active, setActive] = React.useState(false); - if (!isConfigLoaded) return null; - return - setActive(!active)}> - - Advanced Settings - - - - - - - - - - - - - - - - - - - - {isVideoLevel && - - - - - - - } - - - - - - - - - - - - - - - - + return + + + + + + + + + + {isVideoLevel && + + + + } + + + + + + + +} + +/** A labeled Toggle backed by `form.getCustomProps`. */ +function AudioOnlyToggleLikeVideoSetting({form, name, path, label, icon}) { + const [inputProps] = form.getCustomProps({name, path}); + return } export function CompressSinglefileToggle({form}) { - return <> + const [inputProps] = form.getCustomProps({name: 'compress_singlefile', path: 'settings.compress_singlefile'}); + return
- - + +
} function AdvancedDownloadSettings({form}) { - const [active, setActive] = React.useState(false); + const [inputProps] = form.getCustomProps({ + name: 'skip_already_downloaded', + path: 'settings.skip_already_downloaded' + }); - return - setActive(!active)}> - - Advanced Settings - - - - - - - - - - - + return + + } export function VideosDownloadForm({ @@ -667,77 +675,42 @@ export function VideosDownloadForm({ // This form can handle a single Video download, or multiple video downloads. const urlInput = singleDownload ? - - : ; + + : ; - return -
- - Videos -
+ return e.preventDefault()}> +
Videos

Download each video at the URLs provided below.

- - - {urlInput} - - - - - - - - - - - - - - - - - - + + {urlInput} + + + {form.formData?.settings?.audio_only - ? - - - - - : - + ? + : +
setUserChangedResolutions(true)} /> - - +
+
- - +
+
} - - - - - - - - - - + + {showMessage && } - - - {actions ? actions({onCancel: localOnCancel, form}) : - } - - -
- + {actions ? actions({onCancel: localOnCancel, form}) : + } + + } @@ -925,108 +898,67 @@ export function ChannelDownloadForm({ } }, [form.formData]); - const onceMessage = - Download Once - You have selected a frequency of Once, this is useful when you want to download - all videos in a Playlist, and when you do not want to download any videos added to the playlist - in the future. + const onceMessage = + You have selected a frequency of Once, this is useful when you want to download + all videos in a Playlist, and when you do not want to download any videos added to the playlist + in the future. ; // Default to "new" download buttons. actions = actions || DownloadFormButtons; const actionsElm = actions({onDelete, onCancel, form}); - return
-
Channel / Playlist
+ return e.preventDefault()}> +
Channel / Playlist
- - - - - - - - - - - - - + + + + +
- - +
+
- - - {form.formData.frequency === 0 && {onceMessage}} - - - - - - - - - - - - - - - - - - - - - +
+
+ {form.formData.frequency === 0 && onceMessage} + + + + + + + + + {form.formData?.settings?.audio_only - ? - - - - - : - + ? + : +
setUserChangedResolutions(true)} /> - - +
+
- - +
+
} - - - - - - - - - - - - - - - - - - + + + + + + {showMessage && } - - - {actionsElm} - - -
-
+ {actionsElm} + + } export function EditChannelDownloadForm({ @@ -1064,15 +996,9 @@ export function EditChannelDownloadForm({ } function SuccessfulDownloadSubmitMessage() { - return - - Download Submitted - - View downloads - - - - + return + View downloads + } export function EditArchiveDownloadForm({ @@ -1166,43 +1092,23 @@ export function ArchiveDownloadForm({ } const urlInput = singleDownload ? - - : ; + + : ; - return
-
Archives
+ return e.preventDefault()}> +
Archives

Create a Singlefile Archive for each of the URLs provided below.

- - - - {urlInput} - - - - - - - - - - - - - - - - - + + {urlInput} + + + {showMessage && } - - - {actions ? actions({onCancel: localOnCancel, form}) : - } - - - -
+ {actions ? actions({onCancel: localOnCancel, form}) : + } + + } export function RSSDownloadForm({download, submitter, onDelete, onCancel, actions, clearOnSuccess = true}) { @@ -1323,106 +1229,58 @@ export function RSSDownloadForm({download, submitter, onDelete, onCancel, action let downloaderRows; if (form.formData.sub_downloader === Downloaders.Video) { downloaderRows = <> - - - - - - - - - - - - - - - - - - + + + + + + {form.formData?.settings?.audio_only - ? - - - - - : - + ? + : +
setUserChangedResolutions(true)} /> - - +
+
- - +
+
} - - - - - - - - - - - - - + + + + + ; } else if (form.formData.sub_downloader === Downloaders.Archive) { downloaderRows = <> - - - - - - - - - - + + ; } - return
-
RSS Feed
+ return e.preventDefault()}> +
RSS Feed

Download each link provided by this RSS feed using the selected downloader.

- - - - - - - - - - - - - - - - - - - + + + + + + + {downloaderRows} {showMessage && } - - - {actionsElm} - - - -
+ {actionsElm} + + } export function EditRSSDownloadForm({download, onDelete, onCancel, actions = EditDownloadFormButtons}) { @@ -1486,28 +1344,16 @@ export function EditZimDownloadForm({download, onDelete, onCancel, actions = Edi actions = actions || DownloadFormButtons; const actionsElm = actions({onDelete, onCancel, form}); - return
+ return e.preventDefault()}>
Zim File
- - - - - - - - - - - + + + {showMessage && } - - - {actionsElm} - - - -
+ {actionsElm} + + } export function FilesDownloadForm({ @@ -1549,39 +1395,19 @@ export function FilesDownloadForm({ const actionsElm = actions({onCancel, form}); - return
-
Files
+ return e.preventDefault()}> +
Files

Download each file at the URLs provided below.

- - - - - - - - - - - - - - - - - - - - - + + + + + {showMessage && } - - - {actionsElm} - - - -
+ {actionsElm} + + } const suffixValidator = (value) => { @@ -1602,7 +1428,7 @@ const suffixValidator = (value) => { } export function SuffixFormInput({form, name = 'suffix', path = 'settings.suffix'}) { - return - : ; + + : ; // Default to "new" download buttons. actions = actions || DownloadFormButtons; const actionsElm = actions({onDelete, onCancel, form}); - return
-
Scrape Files
+ return e.preventDefault()}> +
Scrape Files

Search each of the URLs for files matching the suffix (.pdf, etc.).

- - - - {urlInput} - - - - + + {urlInput} + +
- - +
+
- - - - - - - - - - +
+
+ + + + {showMessage && } - - - {actionsElm} - - -
-
+ {actionsElm} + + } export function EditScrapeFilesDownloadForm({download, onDelete, onCancel, onSuccess}) { @@ -1733,6 +1547,67 @@ export function EditScrapeFilesDownloadForm({download, onDelete, onCancel, onSuc /> } +/** URL field for a single URL (Semantic's `UrlInput`). */ +function UrlField({form, required = true, name = 'url', path = 'url', disabled = false}) { + const validator = (i) => validURL(i) ? null : 'Invalid URL'; + return +} + +/** Multiline URLs field (Semantic's `UrlsTextarea`). */ +function UrlsField({name = 'urls', required, form}) { + required = required !== undefined; + + const validator = (value) => { + if (!validURLs(value)) { + return 'Invalid URLs'; + } + }; + + const [inputProps, inputAttrs] = form.getInputProps({name, validator, required}); + + const handleDrop = (e) => { + if (e) e.preventDefault(); + const droppedUrl = e.dataTransfer.getData('text'); + let urls = (inputProps.value || '').split('\n'); + urls = [...urls, droppedUrl]; + urls = urls.filter(i => !!i).join('\n'); + inputAttrs.localSetValue(`${urls}\n`); + }; + + const handleKeyDown = (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + form.onSubmit(); + } + }; + + return
+ +