Adding videos page hotkeys to help play and navigate videos. Adding f hotkey for all local search fields (channel/domain/etc). Focusing when opening cookie modals.

This commit is contained in:
Roland 2026-02-23 17:29:09 -07:00
parent 21c96d9501
commit ad0d93714a
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
8 changed files with 166 additions and 14 deletions

View file

@ -8,6 +8,7 @@ import {
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
import {useHotkeys} from "react-hotkeys-hook";
import {Media, SettingsContext, StatusContext} from "./contexts/contexts";
import {DownloadMenu} from "./components/Download";
import {
@ -308,6 +309,14 @@ function DashboardRecentFiles() {
export function DashboardPage() {
const navigate = useNavigate();
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
// The search the user submitted.
const {searchStr, setSearchStr, activeTags, isEmpty, anyTag} = useSearch();
@ -384,6 +393,7 @@ export function DashboardPage() {
handleResultSelect={handleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={searchInputRef}
{...props}
/>;
};

View file

@ -1,4 +1,5 @@
import React, {useContext, useState} from "react";
import {useHotkeys} from "react-hotkeys-hook";
import {
CardContent,
CardDescription,
@ -453,6 +454,14 @@ export function DomainsPage() {
const [domains] = useDomains();
const [searchStr, setSearchStr] = useOneQuery('domain');
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
// Header section matching ChannelsPage pattern
const header = <div style={{marginBottom: '1em'}}>
@ -467,6 +476,7 @@ export function DomainsPage() {
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
@ -1045,6 +1055,14 @@ function ArchiveSettingsPage() {
function ArchivesPage() {
const [selectedArchives, setSelectedArchives] = useState([]);
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
const {domain, domains} = useSearchDomain();
@ -1150,6 +1168,7 @@ function ArchivesPage() {
searchStr={localSearchStr}
onSubmit={setSearchStr}
placeholder='Search Archives...'
inputRef={searchInputRef}
/>;
// Domain header with edit link when filtering by domain

View file

@ -1,4 +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";
@ -523,6 +524,14 @@ export function ChannelsPage() {
const [channels] = useChannels();
const [searchStr, setSearchStr] = useOneQuery('name');
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
// Header section matching DomainsPage pattern
const header = <div style={{marginBottom: '1em'}}>
@ -537,6 +546,7 @@ export function ChannelsPage() {
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
inputRef={searchInputRef}
/>
</Grid.Column>
<Grid.Column textAlign='right'>

View file

@ -421,7 +421,7 @@ export function scrollToTopOfElement(element, smooth = true) {
});
}
const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small', placeholder = 'Search...', icon = 'search', clearDisabled = null) => {
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);
@ -497,6 +497,7 @@ const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small
size={size}
className='search-input'
action={action}
ref={inputRef}
/>;
return {value, submitted, clearButton, input, localOnSubmit, handleChange}
@ -510,10 +511,11 @@ export function SearchInput({
size = 'small',
placeholder = 'Search...',
icon = 'search',
inputRef = null,
...props
}) {
// A Semantic <Input> with a Clear button as the action.
let {input, localOnSubmit} = useClearableInput(searchStr, onChange, onClear, onSubmit, size, placeholder, icon);
let {input, localOnSubmit} = useClearableInput(searchStr, onChange, onClear, onSubmit, size, placeholder, icon, null, inputRef);
return <Form onSubmit={localOnSubmit} {...props} className='search-container'>
{input}

View file

@ -65,7 +65,7 @@ function ShortcutKeys({keys, isMac}) {
case 'escape':
return 'Esc';
default:
return part.toUpperCase();
return part.toLowerCase();
}
});
@ -78,7 +78,7 @@ function ShortcutKeys({keys, isMac}) {
{idx > 0 && ' / '}
{seqParts.map((p, i) => (
<React.Fragment key={i}>
<KeyboardKey>{p.toUpperCase()}</KeyboardKey>
<KeyboardKey>{p.toLowerCase()}</KeyboardKey>
{i < seqParts.length - 1 && ' then '}
</React.Fragment>
))}
@ -121,7 +121,7 @@ export default function HelpModal({open, onClose}) {
const groupedShortcuts = groupShortcutsByCategory(SHORTCUTS);
// Order categories
const categoryOrder = ['Search', 'General', 'Help', 'Navigation'];
const categoryOrder = ['Search', 'General', 'Help', 'Navigation', 'Page Search', 'Video Player'];
const orderedCategories = categoryOrder.filter(cat => groupedShortcuts[cat]);
return (

View file

@ -24,6 +24,16 @@ export const SHORTCUTS = [
{keys: 'g f', description: 'Go to Files', category: 'Navigation', path: '/files'},
{keys: 'g s', description: 'Go to Settings', category: 'Navigation', path: '/admin'},
{keys: 'g i', description: 'Go to Inventory', category: 'Navigation', path: '/inventory'},
// Page Search
{keys: 'f', description: 'Focus search input', category: 'Page Search'},
// Video Player
{keys: 'k', description: 'Play/pause video', category: 'Video Player'},
{keys: 'j', description: 'Jump back 15 seconds', category: 'Video Player'},
{keys: 'l', description: 'Jump forward 30 seconds', category: 'Video Player'},
{keys: 'n', description: 'Navigate to newer video', category: 'Video Player'},
{keys: 'p', description: 'Navigate to older video', category: 'Video Player'},
];
function SearchModal({open, onClose}) {

View file

@ -1,4 +1,5 @@
import React, {useContext} from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
import {deleteVideos, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
import {Link, useNavigate, useParams} from "react-router";
import _ from "lodash";
@ -172,6 +173,21 @@ const Comments = ({comments, video}) => {
</CommentGroup>
}
// Generate a video page URL for a file object.
function getVideoLink(file) {
if (!file || !file.video) return null;
const {video} = file;
const channel = video.channel ? video.channel : null;
if (file.slug) {
return `/videos/video/${file.slug}`;
} else if (channel) {
return `/videos/channel/${channel.id}/video/${video.id}`;
} else {
return `/videos/video/${video.id}`;
}
}
function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const {theme} = useContext(ThemeContext);
@ -194,6 +210,51 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const {channel} = useChannel(video && video.channel_id ? video.channel_id : channelId);
const {comments, captions} = useVideoExtras(videoFile?.video?.id);
// Keyboard shortcuts for video player controls
// k: Play/pause
useHotkeys('k', () => {
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}
}, {enableOnFormTags: false});
// j: Jump back 15 seconds
useHotkeys('j', () => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 15);
}
}, {enableOnFormTags: false});
// l: Jump forward 30 seconds
useHotkeys('l', () => {
if (videoRef.current) {
videoRef.current.currentTime = Math.min(
videoRef.current.duration || Infinity,
videoRef.current.currentTime + 30
);
}
}, {enableOnFormTags: false});
// n: Navigate to newer video
useHotkeys('n', () => {
const link = getVideoLink(nextFile);
if (link) {
navigate(link);
}
}, {enableOnFormTags: false}, [nextFile, navigate]);
// p: Navigate to older video
useHotkeys('p', () => {
const link = getVideoLink(prevFile);
if (link) {
navigate(link);
}
}, {enableOnFormTags: false}, [prevFile, navigate]);
if (videoFile === null) {
return <VideoPlaceholder/>;
}

View file

@ -1,5 +1,6 @@
import React, {useContext, useEffect, useState} from 'react';
import React, {useContext, useEffect, useRef, useState} from 'react';
import {Link, Route, Routes, useParams} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {
APIButton,
CardLink,
@ -33,7 +34,8 @@ import {
Form,
FormDropdown,
FormInput,
FormTextArea,
FormField,
TextArea,
Image,
Label,
PlaceholderHeader,
@ -84,6 +86,14 @@ function VideosPage() {
const {channelId} = useParams();
const {searchParams} = React.useContext(QueryContext);
const [selectedVideos, setSelectedVideos] = useState([]);
const searchInputRef = React.useRef();
useHotkeys('f', (e) => {
e.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, {enableOnFormTags: false});
let searchOrder = defaultFileOrder;
if (searchParams.get('order')) {
@ -206,6 +216,7 @@ function VideosPage() {
onClear={() => setLocalSearchStr(null)}
onSubmit={setSearchStr}
placeholder='Search Videos...'
inputRef={searchInputRef}
/>;
return <>
@ -543,6 +554,31 @@ function CookiesSettingsSection() {
const [password, setPassword] = useState('');
const [unlockPassword, setUnlockPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const unlockPasswordRef = useRef(null);
const cookiesContentRef = useRef(null);
useEffect(() => {
if (uploadModalOpen) {
const timer = setTimeout(() => {
if (cookiesContentRef.current) {
cookiesContentRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}
}, [uploadModalOpen]);
useEffect(() => {
if (unlockModalOpen) {
// Delay focus to allow modal animation to complete
const timer = setTimeout(() => {
if (unlockPasswordRef.current) {
unlockPasswordRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}
}, [unlockModalOpen]);
const fetchStatus = async () => {
setLoading(true);
@ -693,13 +729,16 @@ function CookiesSettingsSection() {
<Modal.Header>Upload Cookies</Modal.Header>
<Modal.Content>
<Form>
<FormTextArea
label='Cookies Content'
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.target.value)}
rows={10}
/>
<FormField>
<label>Cookies Content</label>
<TextArea
ref={cookiesContentRef}
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
value={cookiesContent}
onChange={(e) => setCookiesContent(e.target.value)}
rows={10}
/>
</FormField>
<FormInput
label='Encryption Password'
type='password'
@ -736,6 +775,7 @@ function CookiesSettingsSection() {
<Modal.Content>
<Form>
<FormInput
input={{ref: unlockPasswordRef}}
label='Password'
type='password'
placeholder='Enter your encryption password'