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:
salirezav
2025-10-31 18:06:40 -04:00
parent 0b724fe59b
commit 00d4e5b275
17 changed files with 762 additions and 61 deletions

View File

@@ -14,6 +14,8 @@
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
@@ -1460,6 +1462,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2012,6 +2034,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -6,8 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3001",
"preview": "vite preview --port 3001"
"preview": "vite preview --port 3001",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3001 --cors -c-1"
},
"dependencies": {
"react": "^19.1.0",
@@ -15,14 +17,14 @@
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@vitejs/plugin-react": "^4.6.0",
"@tailwindcss/vite": "^4.1.11",
"tailwindcss": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"vite": "^7.0.4",
"typescript": "~5.8.3"
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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} />
)

View File

@@ -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;
}
}

View File

@@ -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' } })