From 93bcee7250f4c5300f5a9c3f2e7048ccc5fae89f Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 13:02:57 -0600 Subject: [PATCH 01/16] ui: Channels.js and Playlists.js off Semantic Migrates both files onto the token-driven component library: Grid -> Mantine Grid/Grid.Col, Segment -> Panel, Message/Header/Modal/Statistic compounds -> ui equivalents, Semantic Form/Input -> plain form + TextInput, and button colors -> role. Confirm/delete flows keep their guards and now report through Button role='danger'/APIButton role. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/Channels.js | 295 ++++++++++++++------------------ app/src/components/Playlists.js | 253 ++++++++++++++------------- 2 files changed, 250 insertions(+), 298 deletions(-) diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js index c6669b3e..44feaecf 100644 --- a/app/src/components/Channels.js +++ b/app/src/components/Channels.js @@ -1,6 +1,5 @@ import React, {useState} from "react"; import {useHotkeys} from "react-hotkeys-hook"; -import {Grid, StatisticLabel, StatisticValue,} from "semantic-ui-react"; import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo} from "../api"; import {CollectionTagModal} from "./collections/CollectionTagModal"; import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal"; @@ -18,11 +17,22 @@ import { WROLModeMessage } from "./Common"; import {Link, useNavigate, useParams} from "react-router"; -import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {useChannel, useChannels, useOneQuery} from "../hooks/customHooks"; import _ from "lodash"; -import {Button, Form, Header, Loader, Modal, Segment, Statistic} from "./Theme"; -import {toast} from "./ui"; +import { + Button, + Grid, + Group, + Header, + Loading, + Message, + Modal, + Panel, + Stack, + Statistic, + StatisticGroup, + toast, +} from "./ui"; import {RecurringDownloadsTable} from "./admin/Downloads"; import {InputForm, ToggleForm} from "../hooks/useForm"; import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download"; @@ -58,29 +68,16 @@ function ChannelStatistics({statistics}) { return <> } - return + return
Statistics
- - {statistics.video_count} - Videos - - - {humanFileSize(statistics.size, true)} - Total Size - - - {humanFileSize(statistics.largest_video, true)} - Largest Video - - - {secondsToFullDuration(statistics.length)} - Total Duration - - - {humanNumber(statistics.video_tags)} - Video Tags - -
+ + + + + + + + } @@ -97,7 +94,7 @@ export function ChannelEditPage() { useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`); if (!form.ready) { - return ; + return ; } const handleRefreshChannel = async (e) => { @@ -169,7 +166,7 @@ export function ChannelEditPage() { }; const deleteButton = Delete; const refreshButton = setTagEditModalOpen(true)} - color='violet' + role='primary' style={{marginTop: '1em'}} >Tag; const reorganizeButton = ( setReorganizeModalOpen(true)} obeyWROLMode={true} @@ -222,8 +219,7 @@ export function ChannelEditPage() { {channel?.needs_reorganization && ( - - File Format Changed +

The file name format has changed. Click "Reorganize Files" to move existing files to match the new format. @@ -239,8 +235,8 @@ export function ChannelEditPage() { appliedTagName={channel?.tag_name} onSubmit={handleSubmit} > - - + + - - + + - - - - + + - - - {(channel.url || channel.rss_url) && - - + + {(channel.url || channel.rss_url) && + - - {channel.url && - -

URL
- {channel.url} - - } - {channel.rss_url && - -
RSS URL
- {channel.rss_url} -
-
} - + + {channel.url &&
+
URL
+ {channel.url} +
} + {channel.rss_url &&
+
RSS URL
+ {channel.rss_url} +
} +
- - } + } + {/* Tag Modal */} @@ -314,22 +302,14 @@ export function ChannelEditPage() { /> {/* Downloads Segment */} - - - - -
Downloads
-
- - - -
-
- setDownloadModalOpen(false)}> + + +
Downloads
+ +
+ setDownloadModalOpen(false)}>
New Channel Download
-
+ {channel && channel.statistics && } ; @@ -434,88 +414,74 @@ export function ChannelNewPage() { let messageRow; if (error || success) { - messageRow = - - {error && - } - {success && - } - - + messageRow = + {error && + {messageContent}} + {success && + {messageContent}} + } return <> - +
New Channel
-
e.preventDefault()} > - - - - - - - - - - - - - - + + + + + + + + + + {messageRow} - - - - - + + + + +
Save - - +
+
- -
+ + ; } @@ -534,37 +500,28 @@ export function ChannelsPage() { }, {enableOnFormTags: false}); // Header section matching DomainsPage pattern - const header =
- - - - setSearchStr('')} - onChange={setSearchStr} - onSubmit={null} - inputRef={searchInputRef} - /> - - - - - - - - -
; + const header = + setSearchStr('')} + onChange={setSearchStr} + onSubmit={null} + inputRef={searchInputRef} + /> + + + + ; // Empty state if (channels && channels.length === 0) { return <> {header} - - No channels exist yet! - Create one. + + Create one. ; } diff --git a/app/src/components/Playlists.js b/app/src/components/Playlists.js index 195e3529..158807e7 100644 --- a/app/src/components/Playlists.js +++ b/app/src/components/Playlists.js @@ -1,9 +1,19 @@ import React, {useEffect, useState} from "react"; import {Link, Route, Routes, useNavigate, useParams} from "react-router"; -import {Grid, Image, Input, Message} from "semantic-ui-react"; -import {toast} from "./ui"; - -import {Button, Form, Header, Icon, Loader, Modal, Segment, Table} from "./Theme"; +import {IconMapPin} from "@tabler/icons-react"; +import { + Button, + Group, + Header, + Icon, + Loading, + Message, + Modal, + Panel, + Table, + TextInput, + toast, +} from "./ui"; import { APIButton, BackButton, @@ -35,7 +45,6 @@ import { setPlaylistTag, updatePlaylist, } from "../api"; -import {ThemeContext} from "../contexts/contexts"; import {TagsSelector} from "../Tags"; @@ -119,39 +128,36 @@ export function PlaylistsPage() { } }; - const header =
- - - - setSearchStr('')} - onChange={setSearchStr} - onSubmit={null} - inputRef={searchInputRef} - /> - - - - - - -
; + const header = + setSearchStr('')} + onChange={setSearchStr} + onSubmit={null} + inputRef={searchInputRef} + /> + + ; const createModal = setModalOpen(false)} size='tiny'> New Playlist -
- - - setName(value)}/> - - -
- - + +
; @@ -185,22 +191,24 @@ export function PlaylistsPage() { } -// Icon (name + color) for a playlist item. File items use the FileGroup's mimetype/model icon -// and color (video, pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed. -function itemIcon(item) { +// Icon for a playlist item. File items use FileGroup's mimetype/model icon and color (video, +// pdf, ebook, image, ...) so they match the rest of the UI; zim/url are fixed. +function ItemIcon({item}) { if (item.item_kind === 'file' && item.file_group) { const fg = item.file_group; const lowerPath = (fg.primary_path || '').toLowerCase(); - return {name: mimetypeIconName(fg.mimetype, lowerPath), color: mimetypeColor(fg.mimetype, lowerPath)}; + const name = mimetypeIconName(fg.mimetype, lowerPath); + const color = mimetypeColor(fg.mimetype, lowerPath); + return ; } - if (item.item_kind === 'zim') return {name: 'book'}; + if (item.item_kind === 'zim') return ; if (item.item_kind === 'url') { // A map-location URL (e.g. /map?lat=&lon=) gets a map marker; other URLs get the link icon. const u = (item.url || '').toLowerCase(); - if (u.startsWith('/map')) return {name: 'map marker alternate'}; - return {name: 'linkify'}; + if (u.startsWith('/map')) return ; + return ; } - return {name: 'file'}; + return ; } @@ -278,13 +286,13 @@ function ItemTitle({item, label}) { // The ordered items table, shared by the view and edit pages. When `editable`, the reorder/remove // action buttons (and their column) are shown; otherwise the table is read-only. function PlaylistItemsTable({items, editable, onMove, onRemove}) { - return + return
- # - Item + # + Item Title - {editable && } + {editable && } @@ -292,23 +300,23 @@ function PlaylistItemsTable({items, editable, onMove, onRemove}) { const label = itemLabel(item); const poster = item.item_kind === 'file' && item.file_group ? findPosterPath(item.file_group) : null; - return - {String(index + 1).padStart(2, '0')} - + return + {String(index + 1).padStart(2, '0')} + {poster - ? - : } + ? + : } - {editable && - ; @@ -504,47 +511,35 @@ export function PlaylistEditPage() { appliedTagName={playlist.tag_name} onSubmit={handleSave} > - - - - - setName(value)} - /> - - - - - - setDirectory(value || '')} - /> - - - - - - - - setDescription(value)} - /> - - - + setName(e.currentTarget.value)} + /> +
+ + setDirectory(value || '')} + /> +
+
+ setDescription(e.currentTarget.value)} + /> +
{/* Tag Modal */} @@ -559,34 +554,34 @@ export function PlaylistEditPage() { /> {/* Items Segment */} - +
Items
{items.length === 0 - ? - This playlist is empty - {editable && Add a link below.} + ? + {editable && 'Add a link below.'} : }
Add a link
-
- - - setUrl(value)}/> - - - setUrlTitle(value)}/> - - - - - - -
+
{ + e.preventDefault(); + handleAddUrl(); + }}> + + setUrl(e.currentTarget.value)}/> + setUrlTitle(e.currentTarget.value)}/> + + + + ; } From 84b50c158fb1bc82784e16a853ed0a1333935625 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 13:07:09 -0600 Subject: [PATCH 02/16] ui: migrate VideoPlayer.js off Semantic UI React Replaces semantic-ui-react and ./Theme imports with the token-driven ./ui library: Segment->Panel, Tab->Tabs (Tabs.List/Tab/Panel, keepMounted=false to preserve per-tab lazy render/fetch), Loader active->Loading, Input-> TextInput, Grid/Grid.Column->Mantine Grid/Grid.Col, Container->plain div, color=/APIButton->role=. Comment/CommentGroup and Transition have no ui equivalent, so the comment thread and audio-poster fade are rebuilt from plain elements/tokens. The audio poster wrapper (custom play/pause/volume glyphs drawn over the poster image) gets .media so night mode tints the composite as one unit; the video/audio elements and poster img stay untagged since they are already filtered leaves. Added ARIA button semantics to the poster click target since it has no native href/button. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/VideoPlayer.js | 439 +++++++++++++++--------------- 1 file changed, 223 insertions(+), 216 deletions(-) diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index c0604528..0e88cdc9 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -1,4 +1,4 @@ -import React, {useContext, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {useHotkeys} from 'react-hotkeys-hook'; import {deleteFileGroups, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api"; import {Link, useNavigate, useParams} from "react-router"; @@ -17,17 +17,14 @@ import { useMediaSession, useTitle } from "./Common"; -import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; -import Container from "semantic-ui-react/dist/commonjs/elements/Container"; import {VideoPlaceholder} from "./Placeholder"; import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks"; -import {ThemeContext} from "../contexts/contexts"; -import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme"; +import {Button, Grid, Header, Icon, Label, Loading, Panel, Tabs, TextInput} from "./ui"; import {VideoCard} from "./Videos"; import {TagsSelector} from "../Tags"; import {AddToPlaylistButton} from "./AddToPlaylist"; -import {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react"; import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; +import {IconPlayerPause, IconPlayerPlay} from "@tabler/icons-react"; const MEDIA_PATH = '/media'; @@ -117,7 +114,7 @@ const NoComments = ({video}) => {

No comments have been been downloaded. Refresh the video?

Refresh @@ -132,43 +129,43 @@ const NoComments = ({video}) => { } } +// Semantic's / have no equivalent in the token library -- this is a nested +// comment thread, not a reusable widget, so it is built here from plain elements and tokens. const VideoComment = ({comment, children}) => { - const {t} = React.useContext(ThemeContext); - const {is_favorited, like_count, author, timestamp, text, author_is_uploader} = comment; // Author comments and favorited comments are important. let specialIcon = null; if (author_is_uploader) { - specialIcon = ; + specialIcon = ; } else if (is_favorited) { - specialIcon = ; + specialIcon = ; } - const dateElm =
{isoDatetimeToAgoPopup(timestamp * 1000)}
; - const likesElm = like_count &&
- {humanNumber(comment['like_count'])} -
; - - return - - - {specialIcon}{author} - - - {dateElm} - {likesElm} - - - - - + return
+
+ {specialIcon}{author} +
+
+
{isoDatetimeToAgoPopup(timestamp * 1000)}
+ {like_count ?
+ {humanNumber(comment['like_count'])} +
: null} +
+
+ +
{children && !_.isEmpty(children) && - +
{children.map((i, key) => )} - +
} - +
} @@ -202,9 +199,9 @@ const Comments = ({comments, video}) => { groupedByParent[obj.parent] = _.sortBy(children, ['timestamp']); }) - return + return
{rootComments.map((i, key) => )} - +
} // Generate a video page URL for a file object. @@ -214,11 +211,10 @@ function getVideoLink(file) { } function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { - const {theme} = useContext(ThemeContext); - const navigate = useNavigate(); const videoRef = React.useRef(); const [taggedFileGroups, setTaggedFileGroups] = useState(null); + const [activeTab, setActiveTab] = useState('comments'); useTitle(videoFile ? videoFile.title ? videoFile.title : videoFile.name : null); let video; @@ -353,26 +349,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { description = formatVideoDescription(description, setVideoTime) } - const descriptionPane = { - menuItem: 'Description', render: () => -
-                {description}
-            
-
- }; - - const captionsPane = { - menuItem: 'Captions', render: () => - }; - const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix)); const fileSize = (file) => file && file.size - ? ({humanFileSize(file.size)}) + ? ({humanFileSize(file.size)}) : null; const {poster_file, info_json_file} = video; @@ -380,120 +359,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { const ffprobeJsonFile = getFile('.ffprobe.json'); const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'}; const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'}; - const filesPane = { - menuItem: 'Files', render: () => -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{isAudio ? 'Audio File' : 'Video File'} - {video['video_path'] - ? <> - {video['video_path']} - {fileSize(videoPathFile)} - : 'Unknown'} -
Caption Files - {caption_files - ? caption_files.map(i =>
- {i['path']} - {fileSize(i)} -
) - : 'No caption files'} -
Poster File - {poster_file - ? <> - {poster_file['path']} - {fileSize(poster_file)} - : 'No poster file'} -
Info JSON File - {info_json_file - ? <> - {info_json_file['path']} - {fileSize(info_json_file)} - : 'No yt-dlp info json file'} -
ffprobe JSON File - {ffprobeJsonFile - ? <> - {ffprobeJsonFile['path']} - {fileSize(ffprobeJsonFile)} - : 'No ffprobe json file'} -
Directory
- - } - - const commentsPane = { - menuItem: 'Comments', render: () => -
Top Comments
- -
, - }; - - const tabPanes = [commentsPane, descriptionPane, filesPane, captionsPane]; - const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true}; - - const aboutSegment = -
About Video
- - - - -

Size

-

{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}

-
- -

View Count

-

{video.view_count ? humanNumber(video.view_count) : 'N/A'}

-
-
- - -

Codec Names

- <>{video.codec_names ? video.codec_names.map(i => ) : 'N/A'} -
- -

Censored

-

{videoFile.censored ? 'Yes' : 'No'}

-
-
- - -

Source URL

-

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

-
-
-
-
; const localAddTag = async (name) => { await tagFileGroup(videoFile, name); @@ -508,20 +373,18 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { let videoSource = ; const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/'); - let prevNextVideosSegment =

No related videos found.

; + let prevNextVideosPanel =

No related videos found.

; if (prevFile || nextFile) { - prevNextVideosSegment = - - - - {prevFile && <>
Older
} -
- - {nextFile && <>
Newer
} -
-
+ prevNextVideosPanel = + + + {prevFile && <>
Older
} +
+ + {nextFile && <>
Newer
} +
-
+ } let title = video.video_path; @@ -531,19 +394,35 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { title = videoFile.stem; } + const audioPlayPauseLabel = audioPlaying ? 'Pause' : 'Play'; + return <> - +
- +
{isAudio ? <> {posterUrl &&
inside is already a filtered leaf on its own. + className='audio-poster-container media' + role='button' + tabIndex={0} + aria-label={audioPlaying ? 'Pause audio' : 'Play audio'} onClick={() => { if (videoRef.current) { videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause(); } }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (videoRef.current) { + videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause(); + } + } + }} onMouseEnter={() => setAudioPosterHover(true)} onMouseLeave={() => setAudioPosterHover(false)} > @@ -552,16 +431,24 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { alt={mediaTitle || 'Audio thumbnail'} style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}} /> - -
- -
-
- -
- -
-
+ +
}