feat: add AddUrlDialog component and keyboard shortcut for adding URLs
Some checks failed
Frontend CI / lint (push) Failing after 5m6s
Frontend CI / build (push) Has been skipped

This commit is contained in:
JSC
2025-07-06 16:57:41 +02:00
parent 44c9c204f6
commit 3fad1d773e
4 changed files with 357 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { apiService } from '@/services/api'
interface AddUrlDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddUrlDialog({ open, onOpenChange }: AddUrlDialogProps) {
const [url, setUrl] = useState('')
const [loading, setLoading] = useState(false)
// Reset form when dialog opens
useEffect(() => {
if (open) {
setUrl('')
setLoading(false)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) {
toast.error('Please enter a URL')
return
}
if (!isValidUrl(url.trim())) {
toast.error('Please enter a valid URL')
return
}
setLoading(true)
try {
const response = await apiService.post('/api/stream/add-url', { url: url.trim() })
const data = await response.json()
if (response.ok) {
toast.success('URL added to processing queue!')
onOpenChange(false)
} else {
if (response.status === 409) {
toast.error('This URL is already in the system')
} else {
toast.error(data.error || 'Failed to add URL')
}
}
} catch (error) {
console.error('Error adding URL:', error)
toast.error('Failed to add URL. Please try again.')
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !loading) {
handleSubmit(e as any)
}
if (e.key === 'Escape') {
onOpenChange(false)
}
}
const isValidUrl = (string: string) => {
try {
const url = new URL(string)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
const getSupportedServices = () => {
return [
'YouTube',
'SoundCloud',
'Dailymotion',
'Spotify',
'Vimeo',
'Twitch',
'and many more...'
]
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Media URL</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url">Media URL</Label>
<Input
id="url"
type="url"
placeholder="https://www.youtube.com/watch?v=..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
disabled={loading}
autoFocus
/>
<p className="text-sm text-muted-foreground">
Supported services: {getSupportedServices().join(', ')}
</p>
</div>
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !url.trim()}>
{loading ? 'Adding...' : 'Add to Queue'}
</Button>
</div>
</form>
<div className="text-xs text-muted-foreground mt-4 p-3 bg-muted rounded">
<p className="font-medium mb-1">How it works:</p>
<ul className="list-disc list-inside space-y-1">
<li>Paste any supported media URL</li>
<li>Audio will be extracted automatically</li>
<li>New sound will be added to your soundboard</li>
<li>Processing may take a few moments</li>
<li>Check the streams page to monitor progress</li>
</ul>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useCallback } from 'react'
interface KeyboardShortcut {
key: string
ctrlKey?: boolean
altKey?: boolean
shiftKey?: boolean
metaKey?: boolean
callback: () => void
preventDefault?: boolean
}
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
for (const shortcut of shortcuts) {
const keyMatches = event.key.toLowerCase() === shortcut.key.toLowerCase()
const ctrlMatches = !!shortcut.ctrlKey === event.ctrlKey
const altMatches = !!shortcut.altKey === event.altKey
const shiftMatches = !!shortcut.shiftKey === event.shiftKey
const metaMatches = !!shortcut.metaKey === event.metaKey
if (keyMatches && ctrlMatches && altMatches && shiftMatches && metaMatches) {
if (shortcut.preventDefault !== false) {
event.preventDefault()
}
shortcut.callback()
break
}
}
}, [shortcuts])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
}
export function useAddUrlShortcut(onOpen: () => void) {
useKeyboardShortcuts([
{
key: 'u',
ctrlKey: true,
callback: onOpen,
}
])
}

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Play, Square, Volume2 } from 'lucide-react'; import { Play, Square, Volume2, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiService } from '@/services/api'; import { apiService } from '@/services/api';
import { AddUrlDialog } from '@/components/AddUrlDialog';
import { useAddUrlShortcut } from '@/hooks/use-keyboard-shortcuts';
interface Sound { interface Sound {
id: number; id: number;
@@ -75,6 +77,10 @@ export function SoundboardPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [playingSound, setPlayingSound] = useState<number | null>(null); const [playingSound, setPlayingSound] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [addUrlDialogOpen, setAddUrlDialogOpen] = useState(false);
// Setup keyboard shortcut for CTRL+U
useAddUrlShortcut(() => setAddUrlDialogOpen(true));
useEffect(() => { useEffect(() => {
fetchSounds(); fetchSounds();
@@ -162,6 +168,15 @@ export function SoundboardPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Soundboard</h1> <h1 className="text-2xl font-bold">Soundboard</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
onClick={() => setAddUrlDialogOpen(true)}
variant="outline"
size="sm"
title="Add URL (Ctrl+U)"
>
<Plus size={16} className="mr-2" />
Add URL
</Button>
<Button onClick={handleStopAll} variant="outline" size="sm"> <Button onClick={handleStopAll} variant="outline" size="sm">
<Square size={16} className="mr-2" /> <Square size={16} className="mr-2" />
Stop All Stop All
@@ -206,6 +221,12 @@ export function SoundboardPage() {
</div> </div>
</div> </div>
)} )}
{/* Add URL Dialog */}
<AddUrlDialog
open={addUrlDialogOpen}
onOpenChange={setAddUrlDialogOpen}
/>
</div> </div>
); );
} }