Enhance video remote service and UI components
- Updated docker-compose.yml to include new media-api and mediamtx services for improved video handling. - Modified package.json and package-lock.json to add TypeScript types for React and React DOM. - Refactored video-related components (VideoCard, VideoList, VideoModal) for better user experience and responsiveness. - Improved FiltersBar and Pagination components with enhanced styling and functionality. - Added loading and error states in VideoList for better user feedback during data fetching. - Enhanced CSS styles for a more polished look across the application.
This commit is contained in:
@@ -7,10 +7,17 @@ const App: React.FC = () => {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<h1 className="text-2xl font-bold">Video Library</h1>
|
||||
<VideoList onSelect={(id) => setSelected(id)} />
|
||||
<VideoModal fileId={selected} onClose={() => setSelected(null)} />
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="px-6 py-5">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Video Library</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Browse and manage your video recordings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<VideoList onSelect={(id) => setSelected(id)} />
|
||||
<VideoModal fileId={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,23 +9,74 @@ export const FiltersBar: React.FC<Props> = ({ onChange }) => {
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
|
||||
const handleApply = () => {
|
||||
onChange({
|
||||
camera_name: camera || undefined,
|
||||
start_date: start || undefined,
|
||||
end_date: end || undefined
|
||||
})
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setCamera('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
onChange({})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Camera</label>
|
||||
<input value={camera} onChange={e=>setCamera(e.target.value)} placeholder="camera1" className="w-full px-3 py-2 border rounded" />
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Filters</h2>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Start date</label>
|
||||
<input type="date" value={start} onChange={e=>setStart(e.target.value)} className="w-full px-3 py-2 border rounded" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">End date</label>
|
||||
<input type="date" value={end} onChange={e=>setEnd(e.target.value)} className="w-full px-3 py-2 border rounded" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button className="px-3 py-2 border rounded" onClick={()=>onChange({ camera_name: camera || undefined, start_date: start || undefined, end_date: end || undefined })}>Apply</button>
|
||||
<button className="px-3 py-2 border rounded" onClick={()=>{ setCamera(''); setStart(''); setEnd(''); onChange({}) }}>Clear</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Camera Name
|
||||
</label>
|
||||
<input
|
||||
value={camera}
|
||||
onChange={e => setCamera(e.target.value)}
|
||||
placeholder="e.g., camera1"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={start}
|
||||
onChange={e => setStart(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={end}
|
||||
onChange={e => setEnd(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="w-full px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,11 +8,89 @@ type Props = {
|
||||
|
||||
export const Pagination: React.FC<Props> = ({ page, totalPages, onChange }) => {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const pages = []
|
||||
const showPages = 5
|
||||
let startPage = Math.max(1, page - Math.floor(showPages / 2))
|
||||
let endPage = Math.min(totalPages, startPage + showPages - 1)
|
||||
|
||||
if (endPage - startPage < showPages - 1) {
|
||||
startPage = Math.max(1, endPage - showPages + 1)
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button disabled={page<=1} onClick={()=>onChange(page-1)} className="px-3 py-1 border rounded disabled:opacity-50">Prev</button>
|
||||
<span className="text-sm">Page {page} / {totalPages}</span>
|
||||
<button disabled={page>=totalPages} onClick={()=>onChange(page+1)} className="px-3 py-1 border rounded disabled:opacity-50">Next</button>
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{startPage > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onChange(1)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
{startPage > 2 && (
|
||||
<span className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">...</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onChange(p)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-white border border-blue-600'
|
||||
: 'text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && (
|
||||
<span className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">...</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onChange(totalPages)}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onChange(page + 1)}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg className="w-5 h-5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,21 +16,75 @@ export type VideoFile = {
|
||||
|
||||
export type VideoCardProps = {
|
||||
video: VideoFile
|
||||
onClick?: (video: VideoFile) => void
|
||||
onClick?: () => void
|
||||
showMetadata?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({ video, onClick }) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||
onClick={onClick ? () => onClick(video) : undefined}
|
||||
className={`group bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer hover:border-blue-500' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<VideoThumbnail fileId={video.file_id} width={640} height={360} className="w-full h-auto" alt={video.filename} />
|
||||
<div className="relative overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
<VideoThumbnail
|
||||
fileId={video.file_id}
|
||||
width={640}
|
||||
height={360}
|
||||
className="w-full h-auto transition-transform duration-300 group-hover:scale-105"
|
||||
alt={video.filename}
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
{video.is_streamable ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Streamable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
Processing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">{video.camera_name}</div>
|
||||
<div className="font-semibold text-gray-900 truncate" title={video.filename}>{video.filename}</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{video.camera_name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{formatFileSize(video.file_size_bytes)}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate mb-1" title={video.filename}>
|
||||
{video.filename}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{formatDate(video.created_at)}</p>
|
||||
{video.metadata?.duration_seconds && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{Math.floor(video.metadata.duration_seconds / 60)}:{(video.metadata.duration_seconds % 60).toFixed(0).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,18 +31,79 @@ export const VideoList: React.FC<Props> = ({ onSelect }) => {
|
||||
|
||||
useEffect(() => { load(page) }, [page])
|
||||
|
||||
if (loading) return <div className="p-6">Loading videos…</div>
|
||||
if (error) return <div className="p-6 text-red-600">{error}</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<FiltersBar onChange={(f) => { setFilters(f); setPage(1); load(1, f) }} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{videos.map(v => (
|
||||
<VideoCard key={v.file_id} video={v} onClick={onSelect ? () => onSelect(v.file_id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||
|
||||
{loading && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-12">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-blue-200 dark:border-blue-900 border-t-blue-600 dark:border-t-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-medium text-gray-700 dark:text-gray-300">Loading videos...</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Please wait while we fetch your recordings</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error loading videos</h3>
|
||||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => load(page, filters)}
|
||||
className="text-sm font-medium text-red-800 dark:text-red-200 hover:text-red-900 dark:hover:text-red-100 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && videos.length === 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm p-12">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<svg className="w-16 h-16 text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">No videos found</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm">
|
||||
{Object.keys(filters).length > 0
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No videos have been recorded yet. Check back later or contact support if you believe this is an error."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && videos.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span className="font-medium text-gray-900 dark:text-white">{videos.length}</span> videos
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{videos.map(v => (
|
||||
<VideoCard key={v.file_id} video={v} onClick={onSelect ? () => onSelect(v.file_id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
|
||||
type Props = {
|
||||
fileId: string | null
|
||||
@@ -8,16 +8,41 @@ type Props = {
|
||||
|
||||
export const VideoModal: React.FC<Props> = ({ fileId, onClose }) => {
|
||||
if (!fileId) return null
|
||||
const src = `${BASE}/videos/${fileId}/stream`
|
||||
const src = `${BASE}/videos/stream?file_id=${encodeURIComponent(fileId)}`
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] bg-black/50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg w-full max-w-3xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="font-semibold">Video</div>
|
||||
<button onClick={onClose} className="px-2 py-1 border rounded">Close</button>
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl overflow-hidden transform transition-all relative"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button - positioned absolutely in top right corner */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 z-10 inline-flex items-center justify-center w-10 h-10 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 shadow-md hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-all duration-200"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Video Player</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Watch your recording</p>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<video src={src} controls className="w-full h-auto" />
|
||||
<div className="p-4 bg-black">
|
||||
<div className="relative w-full" style={{ aspectRatio: '16/9', maxHeight: '70vh' }}>
|
||||
<video
|
||||
src={src}
|
||||
controls
|
||||
className="w-full h-full rounded-lg object-contain"
|
||||
autoPlay
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
|
||||
type Props = {
|
||||
fileId: string
|
||||
@@ -12,10 +12,10 @@ type Props = {
|
||||
|
||||
export const VideoThumbnail: React.FC<Props> = ({ fileId, width = 320, height = 180, alt = '', className = '', onClick }) => {
|
||||
const p = new URLSearchParams()
|
||||
p.append('file_id', fileId) // supports raw or encoded
|
||||
if (width) p.append('width', String(width))
|
||||
if (height) p.append('height', String(height))
|
||||
const qs = p.toString()
|
||||
const src = `${BASE}/videos/${fileId}/thumbnail${qs ? `?${qs}` : ''}`
|
||||
const src = `${BASE}/videos/thumbnail?${p.toString()}`
|
||||
return (
|
||||
<img src={src} alt={alt} width={width} height={height} className={className} onClick={onClick} />
|
||||
)
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* TailAdmin-style enhancements */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling for webkit browsers */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ type VideoListParams = { camera_name?: string; start_date?: string; end_date?: s
|
||||
type VideoFile = { file_id: string; camera_name: string; filename: string; file_size_bytes: number; format: string; status: string; created_at: string; is_streamable: boolean; needs_conversion: boolean }
|
||||
type VideoListResponse = { videos: VideoFile[]; total_count: number; page?: number; total_pages?: number }
|
||||
|
||||
const BASE = (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
const BASE = (import.meta as any).env?.VITE_MEDIA_API_URL || (import.meta as any).env?.VITE_VISION_API_URL || '/api'
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path, { headers: { 'Accept': 'application/json' } })
|
||||
|
||||
Reference in New Issue
Block a user