Files
new-api/web/default/src/features/usage-logs/components/dialogs/audio-preview-dialog.tsx
T

130 lines
3.9 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ExternalLink, Copy } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
export interface AudioClip {
clip_id?: string
id?: string
title?: string
tags?: string
duration?: number
audio_url?: string
image_url?: string
image_large_url?: string
metadata?: {
tags?: string
duration?: number
}
}
function formatDuration(seconds?: number): string {
if (!seconds || seconds <= 0) return '--:--'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function AudioClipCard({ clip }: { clip: AudioClip }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setHasError(false)
}, [clip.audio_url])
const title = clip.title || t('Untitled')
const tags = clip.tags || clip.metadata?.tags || ''
const duration = clip.duration || clip.metadata?.duration
const imageUrl = clip.image_url || clip.image_large_url
const audioUrl = clip.audio_url
if (!audioUrl) return null
return (
<div className='bg-card flex gap-4 rounded-lg border p-4'>
{imageUrl && (
<img
src={imageUrl}
alt={title}
className='h-20 w-20 shrink-0 rounded-lg object-cover'
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='truncate text-sm font-medium'>{title}</span>
{duration != null && duration > 0 && (
<StatusBadge variant='neutral' className='shrink-0'>
{formatDuration(duration)}
</StatusBadge>
)}
</div>
{tags && (
<p className='text-muted-foreground mb-2 truncate text-xs'>{tags}</p>
)}
{hasError ? (
<div className='flex flex-wrap items-center gap-2'>
<span className='text-destructive text-xs'>
{t('Audio playback failed')}
</span>
<Button
variant='outline'
onClick={() => window.open(audioUrl, '_blank')}
>
<ExternalLink className='h-3 w-3' />
{t('Open in new tab')}
</Button>
<Button
variant='outline'
onClick={() => {
navigator.clipboard.writeText(audioUrl)
toast.success(t('Copied'))
}}
>
<Copy className='h-3 w-3' />
{t('Copy Link')}
</Button>
</div>
) : (
<audio
ref={audioRef}
src={audioUrl}
controls
preload='none'
onError={() => setHasError(true)}
className='h-9 w-full'
/>
)}
</div>
</div>
)
}