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:
93
api-tests.http
Normal file
93
api-tests.http
Normal file
@@ -0,0 +1,93 @@
|
||||
###############
|
||||
# Environment #
|
||||
###############
|
||||
@host = exp-dash
|
||||
@web_port = 8080
|
||||
@api_port = 8000
|
||||
@media_port = 8090
|
||||
@rtsp_port = 8554
|
||||
|
||||
# Base URLs
|
||||
@WEB = http://{{host}}:{{web_port}}
|
||||
@API = http://{{host}}:{{api_port}}
|
||||
@MEDIA = http://{{host}}:{{media_port}}
|
||||
|
||||
|
||||
###########################
|
||||
# Camera Management (API) #
|
||||
###########################
|
||||
### API health
|
||||
GET {{API}}/health
|
||||
|
||||
### System status
|
||||
GET {{API}}/system/status
|
||||
|
||||
### List cameras
|
||||
GET {{API}}/cameras
|
||||
|
||||
### Get single camera status
|
||||
GET {{API}}/cameras/camera1/status
|
||||
|
||||
### Start live preview streaming (MJPEG) for camera1 (served by camera-management-api)
|
||||
POST {{API}}/cameras/camera1/start-stream
|
||||
|
||||
### Stop live preview streaming for camera1
|
||||
POST {{API}}/cameras/camera1/stop-stream
|
||||
|
||||
### View live MJPEG stream in browser (open URL)
|
||||
# {{API}}/cameras/camera1/stream
|
||||
|
||||
### getting a list of all videos
|
||||
GET {{MEDIA}}/videos/?page=10&limit=1
|
||||
|
||||
########################
|
||||
# Media API (media-api) #
|
||||
########################
|
||||
### Media API health
|
||||
GET {{MEDIA}}/health
|
||||
|
||||
### List videos (page 1, 24 per page)
|
||||
GET {{MEDIA}}/videos/?page=1&limit=24
|
||||
|
||||
### List videos filtered by camera name (example: camera1)
|
||||
GET {{MEDIA}}/videos/?page=1&limit=24&camera_name=camera1
|
||||
|
||||
### Get a thumbnail for a specific file (replace FILE_ID from list)
|
||||
# Tip: FILE_ID is URL-encoded relative path, e.g. camera1%2Fmy_video.mp4
|
||||
|
||||
@FILE_ID = camera2_auto_vibratory_conveyor_20251028_094749.mp4
|
||||
GET {{MEDIA}}/videos/{{FILE_ID}}/thumbnail?width=320&height=180
|
||||
|
||||
### Stream a file (basic GET without Range header)
|
||||
GET {{MEDIA}}/videos/{{FILE_ID}}/stream
|
||||
|
||||
### Stream a file with Range header (resume/seek)
|
||||
GET {{MEDIA}}/videos/{{FILE_ID}}/stream
|
||||
Range: bytes=0-1048575
|
||||
|
||||
|
||||
#########################################
|
||||
# RTSP / WebRTC via MediaMTX (mediamtx) #
|
||||
#########################################
|
||||
# Notes:
|
||||
# - RTSP is not directly playable in browsers; use VLC/ffplay/NVR.
|
||||
# - Example RTSP URL (for a path you configure or publish):
|
||||
# rtsp://{{host}}:{{rtsp_port}}/vod
|
||||
# - For on-demand publishing from a file, configure mediamtx.yml with runOnDemand ffmpeg.
|
||||
# - Quick client test examples (Run in your terminal):
|
||||
# ffplay -rtsp_transport tcp rtsp://{{host}}:{{rtsp_port}}/vod
|
||||
# vlc rtsp://{{host}}:{{rtsp_port}}/vod
|
||||
|
||||
# If you enable WebRTC in MediaMTX, you can open its embedded player page (for testing):
|
||||
# See MediaMTX docs; by default we exposed RTSP and WebRTC ports in compose.
|
||||
|
||||
|
||||
###############################################
|
||||
# Useful manual test values (copy/paste below) #
|
||||
###############################################
|
||||
# @name set-variables
|
||||
# file_id examples (replace with values from /videos/):
|
||||
# camera1%2Fcamera1_auto_blower_separator_20251030_160405.mp4
|
||||
# camera2%2Ffoo_20251030_101010.mp4
|
||||
|
||||
|
||||
@@ -67,16 +67,40 @@ services:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- TZ=America/New_York
|
||||
- VITE_MEDIA_API_URL=http://exp-dash:8090
|
||||
- VITE_VISION_API_URL=http://exp-dash:8000
|
||||
volumes:
|
||||
- ./video-remote:/app
|
||||
command: >
|
||||
sh -lc "
|
||||
npm install;
|
||||
npm run build;
|
||||
npx http-server dist -p 3001 --cors -c-1
|
||||
npm run dev:watch
|
||||
"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
|
||||
media-api:
|
||||
build:
|
||||
context: ./media-api
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- MEDIA_VIDEOS_DIR=/mnt/nfs_share
|
||||
- MEDIA_THUMBS_DIR=/mnt/nfs_share/.thumbnails
|
||||
volumes:
|
||||
- /mnt/nfs_share:/mnt/nfs_share
|
||||
ports:
|
||||
- "8090:8090"
|
||||
|
||||
mediamtx:
|
||||
image: bluenviron/mediamtx:latest
|
||||
volumes:
|
||||
- ./mediamtx.yml:/mediamtx.yml:ro
|
||||
- /mnt/nfs_share:/mnt/nfs_share:ro
|
||||
ports:
|
||||
- "8554:8554" # RTSP
|
||||
- "8889:8889" # WebRTC HTTP API
|
||||
- "8189:8189" # WebRTC UDP
|
||||
|
||||
25
media-api/Dockerfile
Normal file
25
media-api/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV MEDIA_VIDEOS_DIR=/mnt/videos \
|
||||
MEDIA_THUMBS_DIR=/mnt/videos/.thumbnails \
|
||||
MEDIA_PORT=8090
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
168
media-api/main.py
Normal file
168
media-api/main.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
MEDIA_DIR = pathlib.Path(os.getenv("MEDIA_VIDEOS_DIR", "/mnt/videos")).resolve()
|
||||
THUMBS_DIR = pathlib.Path(os.getenv("MEDIA_THUMBS_DIR", MEDIA_DIR / ".thumbnails")).resolve()
|
||||
|
||||
app = FastAPI(title="Media API", version="0.1.0")
|
||||
|
||||
# CORS for dashboard at exp-dash:8080 (and localhost for convenience)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://exp-dash:8080", "http://localhost:8080"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"]
|
||||
)
|
||||
|
||||
|
||||
def list_video_files() -> List[pathlib.Path]:
|
||||
exts = {".mp4", ".avi", ".mov", ".mkv"}
|
||||
files: List[pathlib.Path] = []
|
||||
for root, _, fnames in os.walk(MEDIA_DIR):
|
||||
# skip thumbnails dir
|
||||
if THUMBS_DIR.as_posix() in pathlib.Path(root).as_posix():
|
||||
continue
|
||||
for f in fnames:
|
||||
p = pathlib.Path(root) / f
|
||||
if p.suffix.lower() in exts:
|
||||
files.append(p)
|
||||
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
def file_id_from_path(p: pathlib.Path) -> str:
|
||||
return urllib.parse.quote_plus(p.relative_to(MEDIA_DIR).as_posix())
|
||||
|
||||
|
||||
def path_from_file_id(fid: str) -> pathlib.Path:
|
||||
rel = urllib.parse.unquote_plus(fid)
|
||||
p = (MEDIA_DIR / rel).resolve()
|
||||
if not p.is_file() or MEDIA_DIR not in p.parents:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
return p
|
||||
|
||||
|
||||
def ensure_thumbnail(p: pathlib.Path, width: int = 320, height: int = 180) -> pathlib.Path:
|
||||
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
thumb_name = file_id_from_path(p) + f"_{width}x{height}.jpg"
|
||||
out = THUMBS_DIR / thumb_name
|
||||
if out.exists():
|
||||
return out
|
||||
# generate from 1s offset or 0 if too short
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", "1",
|
||||
"-i", str(p),
|
||||
"-vframes", "1",
|
||||
"-vf", f"scale='min({width},iw)':-2,scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2",
|
||||
str(out)
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/videos/")
|
||||
def list_videos(camera_name: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None,
|
||||
limit: int = 24, page: int = 1) -> dict:
|
||||
files = list_video_files()
|
||||
# basic filtering placeholder (camera_name can be inferred from path segments)
|
||||
if camera_name:
|
||||
files = [p for p in files if camera_name in p.as_posix()]
|
||||
total = len(files)
|
||||
start = max(0, (page - 1) * limit)
|
||||
slice_ = files[start:start + limit]
|
||||
items = []
|
||||
for p in slice_:
|
||||
stat = p.stat()
|
||||
items.append({
|
||||
"file_id": file_id_from_path(p),
|
||||
"camera_name": p.parent.name,
|
||||
"filename": p.name,
|
||||
"file_size_bytes": stat.st_size,
|
||||
"format": p.suffix.lstrip('.'),
|
||||
"status": "completed",
|
||||
"created_at": int(stat.st_mtime),
|
||||
"is_streamable": True,
|
||||
"needs_conversion": False,
|
||||
})
|
||||
total_pages = (total + limit - 1) // limit if limit else 1
|
||||
return {
|
||||
"videos": items,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/videos/{file_id:path}/thumbnail")
|
||||
def get_thumbnail(file_id: str, width: int = 320, height: int = 180):
|
||||
p = path_from_file_id(file_id)
|
||||
thumb = ensure_thumbnail(p, width, height)
|
||||
return FileResponse(thumb, media_type="image/jpeg")
|
||||
|
||||
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
|
||||
@app.get("/videos/thumbnail")
|
||||
def get_thumbnail_q(file_id: str, width: int = 320, height: int = 180):
|
||||
p = path_from_file_id(file_id)
|
||||
thumb = ensure_thumbnail(p, width, height)
|
||||
return FileResponse(thumb, media_type="image/jpeg")
|
||||
|
||||
|
||||
def open_file_range(path: pathlib.Path, start: int, end: Optional[int]):
|
||||
file_size = path.stat().st_size
|
||||
if end is None or end >= file_size:
|
||||
end = file_size - 1
|
||||
length = end - start + 1
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(start)
|
||||
chunk = f.read(length)
|
||||
return chunk, file_size, start, end
|
||||
|
||||
|
||||
@app.get("/videos/{file_id:path}/stream")
|
||||
def stream_file(request: Request, file_id: str):
|
||||
p = path_from_file_id(file_id)
|
||||
range_header = request.headers.get("range")
|
||||
if not range_header:
|
||||
return FileResponse(p, media_type="video/mp4")
|
||||
# parse bytes=START-END
|
||||
range_value = range_header.strip().lower().replace("bytes=", "")
|
||||
start_str, _, end_str = range_value.partition("-")
|
||||
try:
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else None
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Bad Range header")
|
||||
chunk, size, start, end = open_file_range(p, start, end)
|
||||
headers = {
|
||||
"Content-Range": f"bytes {start}-{end}/{size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(len(chunk)),
|
||||
"Content-Type": "video/mp4",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
}
|
||||
return Response(content=chunk, status_code=206, headers=headers)
|
||||
|
||||
# Convenience endpoint: pass file_id via query instead of path (accepts raw or URL-encoded)
|
||||
@app.get("/videos/stream")
|
||||
def stream_file_q(request: Request, file_id: str):
|
||||
return stream_file(request, file_id)
|
||||
|
||||
|
||||
|
||||
9
media-api/requirements.txt
Normal file
9
media-api/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
python-multipart==0.0.9
|
||||
pydantic==2.9.2
|
||||
watchfiles==0.21.0
|
||||
|
||||
|
||||
|
||||
|
||||
22
mediamtx.yml
Normal file
22
mediamtx.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
logLevel: info
|
||||
rtsp: yes
|
||||
rtmp: no
|
||||
hls: yes
|
||||
webrtc: yes
|
||||
|
||||
paths:
|
||||
all:
|
||||
# allow any path to be read; publishers can be added on-demand
|
||||
readUser: any
|
||||
sourceOnDemand: no
|
||||
|
||||
# Example on-demand publisher for a demo VOD (adjust file path):
|
||||
# vod:
|
||||
# readUser: any
|
||||
# runOnDemand: |
|
||||
# ffmpeg -re -stream_loop -1 -i /mnt/videos/sample.mp4 -c copy -f rtsp rtsp://localhost:8554/vod
|
||||
# runOnDemandRestart: yes
|
||||
|
||||
|
||||
|
||||
|
||||
29
video-remote/package-lock.json
generated
29
video-remote/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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