mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
21c96d9501
commit
ad0d93714a
8 changed files with 166 additions and 14 deletions
|
|
@ -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}
|
||||
/>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}) {
|
||||
|
|
|
|||
|
|
@ -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/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue