feat: add AddUrlDialog component and keyboard shortcut for adding URLs
This commit is contained in:
146
src/components/AddUrlDialog.tsx
Normal file
146
src/components/AddUrlDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
48
src/hooks/use-keyboard-shortcuts.ts
Normal file
48
src/hooks/use-keyboard-shortcuts.ts
Normal 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,
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { apiService } from '@/services/api';
|
||||
import { AddUrlDialog } from '@/components/AddUrlDialog';
|
||||
import { useAddUrlShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||
|
||||
interface Sound {
|
||||
id: number;
|
||||
@@ -75,6 +77,10 @@ export function SoundboardPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playingSound, setPlayingSound] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [addUrlDialogOpen, setAddUrlDialogOpen] = useState(false);
|
||||
|
||||
// Setup keyboard shortcut for CTRL+U
|
||||
useAddUrlShortcut(() => setAddUrlDialogOpen(true));
|
||||
|
||||
useEffect(() => {
|
||||
fetchSounds();
|
||||
@@ -162,6 +168,15 @@ export function SoundboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Soundboard</h1>
|
||||
<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">
|
||||
<Square size={16} className="mr-2" />
|
||||
Stop All
|
||||
@@ -206,6 +221,12 @@ export function SoundboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add URL Dialog */}
|
||||
<AddUrlDialog
|
||||
open={addUrlDialogOpen}
|
||||
onOpenChange={setAddUrlDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user