Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6c703457c |
@@ -15,6 +15,4 @@ POSTGRES_PASSWORD=mypassword
|
|||||||
POSTGRES_DB=vodSystem
|
POSTGRES_DB=vodSystem
|
||||||
|
|
||||||
FRONTEND_URL=
|
FRONTEND_URL=
|
||||||
BACKEND_URL=
|
BACKEND_URL=
|
||||||
|
|
||||||
STREAM_RING_LENGTH_SECOND=28800
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: gitea.defaoite.dev
|
|
||||||
IMAGE_PREFIX: dylan/vod-system
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit-tests:
|
|
||||||
name: Maven unit tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Java 21
|
|
||||||
uses: https://github.com/actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
cache: maven
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: ./mvnw --batch-mode --no-transfer-progress test
|
|
||||||
|
|
||||||
build-and-push:
|
|
||||||
name: Build and push images
|
|
||||||
needs: unit-tests
|
|
||||||
if: gitea.ref == 'refs/heads/master'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Gitea container registry
|
|
||||||
uses: https://github.com/docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push backend image
|
|
||||||
uses: https://github.com/docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: src/main/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest
|
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache
|
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:buildcache,mode=max
|
|
||||||
|
|
||||||
- name: Build and push frontend image
|
|
||||||
uses: https://github.com/docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./frontend
|
|
||||||
file: frontend/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:latest
|
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache
|
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend:buildcache,mode=max
|
|
||||||
|
|
||||||
- name: Build and push rtmp image
|
|
||||||
uses: https://github.com/docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./nginx
|
|
||||||
file: nginx/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:latest
|
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache
|
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-rtmp:buildcache,mode=max
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy to server
|
|
||||||
needs: build-and-push
|
|
||||||
if: gitea.ref == 'refs/heads/master'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Copy production compose file to server
|
|
||||||
uses: https://github.com/appleboy/scp-action@v0.1.7
|
|
||||||
with:
|
|
||||||
host: ${{ vars.DEPLOY_HOST }}
|
|
||||||
username: ${{ vars.DEPLOY_USER }}
|
|
||||||
port: 22
|
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
source: docker-compose.prod.yml
|
|
||||||
target: ${{ vars.DEPLOY_PATH }}
|
|
||||||
strip_components: 0
|
|
||||||
|
|
||||||
- name: Pull latest images and restart containers
|
|
||||||
uses: https://github.com/appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ vars.DEPLOY_HOST }}
|
|
||||||
username: ${{ vars.DEPLOY_USER }}
|
|
||||||
port: 22
|
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
envs: REGISTRY,REGISTRY_USER,REGISTRY_TOKEN,DEPLOY_PATH
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
|
||||||
cd "$DEPLOY_PATH"
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
|
||||||
env:
|
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
|
||||||
REGISTRY_USER: ${{ gitea.actor }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ build/
|
|||||||
frontend/.flowbite-react
|
frontend/.flowbite-react
|
||||||
|
|
||||||
generated
|
generated
|
||||||
|
.mvn
|
||||||
|
|||||||
3
.mvn/wrapper/maven-wrapper.properties
vendored
3
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,3 +0,0 @@
|
|||||||
wrapperVersion=3.3.4
|
|
||||||
distributionType=only-script
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
|
||||||
144
README.md
144
README.md
@@ -1,121 +1,33 @@
|
|||||||
# VoD System
|
# Overview
|
||||||
|
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
|
||||||
|
|
||||||
A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker.
|
- Upload clips effortlessly and securely.
|
||||||
|
- Edit and trim videos with intuitive controls.
|
||||||
|
- Compress files to specific file sizes.
|
||||||
|
- Organize your clips for quick access and sharing.
|
||||||
|
|
||||||
This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming.
|
This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|------------|---------------------------------------------------------|
|
|
||||||
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
|
|
||||||
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
|
|
||||||
| Database | PostgreSQL 15 |
|
|
||||||
| Streaming | nginx-rtmp |
|
|
||||||
| Auth | Google OAuth 2.0 + JWT |
|
|
||||||
| Runtime | Docker Compose |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- JDK 21+
|
||||||
|
- Node.js 16+
|
||||||
|
- FFMPEG
|
||||||
|
- Google Client keys
|
||||||
|
## Steps
|
||||||
|
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
|
||||||
|
2. Spin up docker container with `docker compose up`
|
||||||
|
3. Run `./mvnw clean package` to build the jar
|
||||||
|
4. Run `./mvnw spring-boot:run` to run the backend section
|
||||||
|
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
|
||||||
|
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
|
||||||
|
|
||||||
- Docker and Docker Compose
|
# Future Plans
|
||||||
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
|
- **Format Handling**
|
||||||
|
- Backend conversion of non-MP4 files via FFMPEG for broader format support
|
||||||
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
|
- **Input Sources**
|
||||||
|
- Support for local file uploads and YouTube imports
|
||||||
---
|
- **Testing**
|
||||||
|
- Unit tests, including coverage for FFMPEG-related functionality
|
||||||
## Getting Started
|
- **API Documentation**
|
||||||
|
- Comprehensive and maintainable backend API docs
|
||||||
### 1. Configure the environment
|
|
||||||
|
|
||||||
Copy the example file and fill in the values:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `.env` and set the following:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
# OAuth credentials from your Google Cloud Console project
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# A long random string used to sign JWTs — treat it like a password
|
|
||||||
JWT_SECRET_KEY=
|
|
||||||
|
|
||||||
# Absolute paths on the host where persistent data will be stored
|
|
||||||
POSTGRES_DATA=/path/to/postgres/data
|
|
||||||
STREAM_DATA=/path/to/stream/data
|
|
||||||
MEDIA_DATA=/path/to/media/data
|
|
||||||
|
|
||||||
# Database credentials — change these from the defaults before deploying
|
|
||||||
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
|
||||||
POSTGRES_USER=myuser
|
|
||||||
POSTGRES_PASSWORD=mypassword
|
|
||||||
POSTGRES_DB=vodSystem
|
|
||||||
|
|
||||||
# The URLs the frontend will use to reach itself and the backend
|
|
||||||
FRONTEND_URL=http://localhost:5173
|
|
||||||
BACKEND_URL=http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start the application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
|
|
||||||
|
|
||||||
Once everything has started, the interfaces are available at:
|
|
||||||
|
|
||||||
- Frontend: [http://localhost:5173](http://localhost:5173)
|
|
||||||
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
|
|
||||||
- RTMP ingest: `rtmp://localhost:1935/live`
|
|
||||||
|
|
||||||
### 3. Authenticate
|
|
||||||
|
|
||||||
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Mode
|
|
||||||
|
|
||||||
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml up
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the backend and frontend separately in your usual workflow.
|
|
||||||
|
|
||||||
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
|
|
||||||
|
|
||||||
The collection is organised into four folders:
|
|
||||||
|
|
||||||
| Folder | Description |
|
|
||||||
|----------|--------------------------------------------------|
|
|
||||||
| Users | Google OAuth login flow, current user |
|
|
||||||
| Media | Upload, compress, clip stream, save section |
|
|
||||||
| Jobs | Poll job status, download completed output |
|
|
||||||
| Streams | Stream history |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Plans
|
|
||||||
|
|
||||||
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
|
|
||||||
- **Input sources** — YouTube imports and additional local upload options
|
|
||||||
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
|
|
||||||
- **API documentation** — comprehensive, auto-generated backend API docs
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Create Marker
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/markers
|
|
||||||
body: json
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"message": "Nice play"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Delete Marker
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
delete {
|
|
||||||
url: {{base_url}}/markers/5
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get Marker
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/markers/5
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get all markers
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/markers
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Markers
|
|
||||||
seq: 6
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,7 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"duration": 60.0,
|
"duration": 60.0
|
||||||
"title": "Hello",
|
|
||||||
"description": "my clip"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Save Stream Section By Markers
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
url: {{base_url}}/media/save/markers
|
|
||||||
body: json
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"startMarkerId": 1,
|
|
||||||
"endMarkerId": 2,
|
|
||||||
"title": "Hello",
|
|
||||||
"description": "my clip"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
script:post-response {
|
|
||||||
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,8 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"startTime": "2026-07-01T21:09:00Z",
|
"startTime": "2026-06-21T17:27:00Z",
|
||||||
"endTime": "2026-07-01T21:12:00Z",
|
"endTime": "2026-06-21T17:28:00Z"
|
||||||
"title": "Hello",
|
|
||||||
"description": "my clip"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Delete VoD
|
|
||||||
type: http
|
|
||||||
seq: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
delete {
|
|
||||||
url: {{base_url}}/vods/1
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get VoD Media
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/vods/1/media
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get VoD Thumbnail
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/vods/1/thumbnail
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Downloads
|
|
||||||
seq: 5
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get VoD
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/vods/1
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Get all vods
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{base_url}}/vods
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: Update VoD
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
patch {
|
|
||||||
url: {{base_url}}/vods/1
|
|
||||||
body: json
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
body:json {
|
|
||||||
{
|
|
||||||
"title": "My VoD",
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: VoDs
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
auth {
|
|
||||||
mode: inherit
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
vars {
|
|
||||||
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
|
|
||||||
uuid:
|
|
||||||
}
|
|
||||||
vars:secret [
|
|
||||||
google_client_id,
|
|
||||||
google_secret
|
|
||||||
]
|
|
||||||
@@ -20,8 +20,6 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# Production deploy: uses pre-built images from the Gitea container registry
|
|
||||||
# instead of building locally. Populated by CI onto the server alongside .env.
|
|
||||||
# Usage: docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15
|
|
||||||
container_name: vod_postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
|
||||||
volumes:
|
|
||||||
- ${POSTGRES_DATA}:/var/lib/postgresql/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-backend:latest
|
|
||||||
container_name: vod_backend
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
volumes:
|
|
||||||
- ${MEDIA_DATA}:/app/media
|
|
||||||
- ${STREAM_DATA}:/app/stream
|
|
||||||
ports:
|
|
||||||
- "8089:8080"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-frontend:latest
|
|
||||||
container_name: vod_frontend
|
|
||||||
environment:
|
|
||||||
VITE_API_URL: ${BACKEND_URL}
|
|
||||||
VITE_FRONTEND_URL: ${FRONTEND_URL}
|
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
ports:
|
|
||||||
- "5173:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
rtmp:
|
|
||||||
image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest
|
|
||||||
container_name: vod_rtmp
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- WEB_SERVER_URL=http://backend:8080
|
|
||||||
ports:
|
|
||||||
- "1935:1935"
|
|
||||||
volumes:
|
|
||||||
- ${STREAM_DATA}:/stream
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
@@ -32,12 +32,11 @@ services:
|
|||||||
container_name: vod_frontend
|
container_name: vod_frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_URL: ${BACKEND_URL}
|
VITE_API_URL: ${BACKEND_URL}
|
||||||
VITE_FRONTEND_URL: ${FRONTEND_URL}
|
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- "5173:80"
|
- "5173:5173"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rtmp:
|
rtmp:
|
||||||
@@ -45,8 +44,6 @@ services:
|
|||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: vod_rtmp
|
container_name: vod_rtmp
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- WEB_SERVER_URL=http://backend:8080
|
- WEB_SERVER_URL=http://backend:8080
|
||||||
ports:
|
ports:
|
||||||
@@ -56,4 +53,4 @@ services:
|
|||||||
pull_policy: build
|
pull_policy: build
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
@@ -1,19 +1,9 @@
|
|||||||
# --- Build stage ---
|
FROM node:20-alpine
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
# --- Production stage ---
|
|
||||||
FROM nginx:1.27-alpine AS prod
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Inject envs at runtime
|
|
||||||
COPY env.sh /docker-entrypoint.d/40-env.sh
|
|
||||||
RUN chmod +x /docker-entrypoint.d/40-env.sh
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
cat <<EOF > /usr/share/nginx/html/env-config.js
|
|
||||||
window._env_ = {
|
|
||||||
VITE_API_URL: "${VITE_API_URL:-}",
|
|
||||||
VITE_FRONTEND_URL: "${VITE_FRONTEND_URL:-}",
|
|
||||||
VITE_GOOGLE_CLIENT_ID: "${VITE_GOOGLE_CLIENT_ID:-}"
|
|
||||||
};
|
|
||||||
EOF
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,29 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/small_icon.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<script>
|
<title>Vite + React + TS</title>
|
||||||
// Apply the stored theme before first paint so a dark session never
|
|
||||||
// flashes light. Mirrors the logic in src/theme/ThemeProvider.tsx.
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var stored = localStorage.getItem("theme");
|
|
||||||
var dark = stored === "dark" ||
|
|
||||||
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
||||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
||||||
} catch (e) {
|
|
||||||
document.documentElement.dataset.theme = "light";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<script src="/env-config.js"></script>
|
|
||||||
<title>The VoD System</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
3507
frontend/package-lock.json
generated
3507
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,21 +8,27 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"postinstall": "flowbite-react patch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.12.2",
|
"@react-oauth/google": "^0.12.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.7",
|
||||||
|
"axios": "^1.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"flowbite-react": "^0.11.8",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
|
"path": "^0.12.7",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-range-slider-input": "^3.2.1",
|
"react-range-slider-input": "^3.2.1",
|
||||||
"react-router-dom": "^7.8.0"
|
"react-router-dom": "^7.8.0",
|
||||||
|
"tailwindcss": "^4.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
|
||||||
"@types/node": "^26.1.1",
|
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -30,9 +36,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-mkcert": "^1.17.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// App.jsx
|
// App.jsx
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
import ClipUpload from './pages/ClipUpload';
|
import ClipUpload from './pages/ClipUpload';
|
||||||
@@ -6,20 +6,10 @@ import ClipEdit from './pages/ClipEdit';
|
|||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import MyClips from './pages/MyClips';
|
import MyClips from './pages/MyClips';
|
||||||
import MyVods from './pages/MyVods';
|
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||||
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
|
||||||
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
|
||||||
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
|
||||||
import EditClip from "./pages/EditClip.tsx";
|
import EditClip from "./pages/EditClip.tsx";
|
||||||
import EditVod from "./pages/EditVod.tsx";
|
|
||||||
import Profile from "./pages/Profile.tsx";
|
import Profile from "./pages/Profile.tsx";
|
||||||
import StreamControl from "./pages/StreamControl.tsx";
|
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import {config} from "./config.ts";
|
|
||||||
import NotFound from "./pages/NotFound.tsx";
|
|
||||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
|
||||||
import ThemeProvider from "./theme/ThemeProvider.tsx";
|
|
||||||
import RequireAuth from "./auth/RequireAuth.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -28,33 +18,20 @@ function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
|
||||||
<ThemeProvider>
|
<Router>
|
||||||
<AuthProvider>
|
<Routes>
|
||||||
<Router>
|
<Route element={<MainLayout />}>
|
||||||
<Routes>
|
<Route path="/" element={<Home />} />
|
||||||
<Route element={<MainLayout />}>
|
<Route path="/create" element={<ClipUpload />} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/create/:id" element={<ClipEdit />} />
|
||||||
<Route path="/create" element={<ClipUpload />} />
|
<Route path="/my-clips" element={<MyClips />} />
|
||||||
<Route path="/create/:id" element={<ClipEdit />} />
|
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||||
|
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route path="/profile" element={<Profile />} />
|
||||||
<Route path="/my-clips" element={<MyClips />} />
|
</Route>
|
||||||
<Route path="/my-vods" element={<MyVods />} />
|
</Routes>
|
||||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
</Router>
|
||||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
|
||||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
|
||||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
|
||||||
<Route path="/profile" element={<Profile />} />
|
|
||||||
<Route path="/stream" element={<StreamControl />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</Router>
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</ GoogleOAuthProvider>
|
</ GoogleOAuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,20 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
|
|
||||||
type AuthContextValue = {
|
|
||||||
/** The currently authenticated user, or null when logged out. */
|
|
||||||
user: User | null;
|
|
||||||
/** True until the initial "who am I" request has resolved. */
|
|
||||||
loading: boolean;
|
|
||||||
/** Exchange a Google credential for a session, then load the user. */
|
|
||||||
login: (googleCredential: string) => Promise<void>;
|
|
||||||
/** End the session and clear the user. The local session is cleared even if the server call fails. */
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export { AuthContext };
|
|
||||||
export type { AuthContextValue };
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import type { User } from "../utils/types.ts";
|
|
||||||
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
|
||||||
import {
|
|
||||||
refreshSession,
|
|
||||||
registerSessionRefreshedHandler,
|
|
||||||
registerUnauthorizedHandler,
|
|
||||||
} from "../utils/api/client.ts";
|
|
||||||
|
|
||||||
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
|
|
||||||
const REFRESH_MARGIN_MS = 60_000;
|
|
||||||
/** How soon to try again when a scheduled refresh fails because the network is down. */
|
|
||||||
const RETRY_DELAY_MS = 30_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the single source of truth for the authenticated user. Everything that
|
|
||||||
* needs to know who is logged in — the layout, the top bar, protected pages —
|
|
||||||
* reads it through the `useAuth` hook rather than fetching the user itself.
|
|
||||||
*
|
|
||||||
* It also keeps the session alive: the access token is refreshed shortly
|
|
||||||
* before it expires, rather than waiting for a request to fail. That matters
|
|
||||||
* for <video> elements, which load media straight from the API and can't
|
|
||||||
* retry on a 401 the way apiFetch does.
|
|
||||||
*/
|
|
||||||
const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
|
|
||||||
const refreshDueRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const cancelScheduledRefresh = useCallback(() => {
|
|
||||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
||||||
refreshTimerRef.current = null;
|
|
||||||
refreshDueRef.current = null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const endSession = useCallback(() => {
|
|
||||||
cancelScheduledRefresh();
|
|
||||||
setUser(null);
|
|
||||||
}, [cancelScheduledRefresh]);
|
|
||||||
|
|
||||||
const scheduleRefreshIn = useCallback((delayMs: number) => {
|
|
||||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
||||||
refreshDueRef.current = Date.now() + delayMs;
|
|
||||||
refreshTimerRef.current = setTimeout(() => {
|
|
||||||
refreshSession()
|
|
||||||
.then((refreshed) => { if (!refreshed) endSession(); })
|
|
||||||
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
|
|
||||||
}, delayMs);
|
|
||||||
}, [endSession]);
|
|
||||||
|
|
||||||
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
|
|
||||||
// reports the new access token lifetime here, so the timer always tracks
|
|
||||||
// the live cookie.
|
|
||||||
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
|
|
||||||
const lifetimeMs = accessTokenExpiresIn * 1000;
|
|
||||||
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
|
|
||||||
}, [scheduleRefreshIn]);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setUser(await getUser());
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch user:", error);
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
registerUnauthorizedHandler(endSession);
|
|
||||||
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
|
|
||||||
}, [endSession, scheduleRefreshBeforeExpiry]);
|
|
||||||
|
|
||||||
// Restore the session on page load. Refreshing first means a returning
|
|
||||||
// user whose access token expired while they were away is logged straight
|
|
||||||
// back in, and gives us the expiry to schedule the next refresh against.
|
|
||||||
useEffect(() => {
|
|
||||||
refreshSession()
|
|
||||||
.catch((error) => console.error("Failed to refresh session:", error))
|
|
||||||
.then(refresh)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
// Timers are throttled in background tabs and paused while the machine
|
|
||||||
// sleeps, so catch up as soon as the user comes back.
|
|
||||||
useEffect(() => {
|
|
||||||
const refreshIfDue = () => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
const due = refreshDueRef.current;
|
|
||||||
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", refreshIfDue);
|
|
||||||
window.addEventListener("online", refreshIfDue);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", refreshIfDue);
|
|
||||||
window.removeEventListener("online", refreshIfDue);
|
|
||||||
};
|
|
||||||
}, [scheduleRefreshIn]);
|
|
||||||
|
|
||||||
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
|
|
||||||
|
|
||||||
const login = useCallback(async (googleCredential: string) => {
|
|
||||||
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
|
|
||||||
await refresh();
|
|
||||||
}, [refresh, scheduleRefreshBeforeExpiry]);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
// Drop the local session even if the server call fails, so the user
|
|
||||||
// is never left looking logged in after clicking log out.
|
|
||||||
endSession();
|
|
||||||
await apiLogout();
|
|
||||||
}, [endSession]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthProvider;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import { useAuth } from "./useAuth";
|
|
||||||
|
|
||||||
const RequireAuth = () => {
|
|
||||||
const { user, loading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (loading) return null;
|
|
||||||
if (!user) return <Navigate to="/" replace state={{ from: location }} />;
|
|
||||||
|
|
||||||
return <Outlet />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RequireAuth;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { AuthContext } from "./AuthContext.ts";
|
|
||||||
import type { AuthContextValue } from "./AuthContext.ts";
|
|
||||||
|
|
||||||
/** Access the centralised auth state. Must be used within an <AuthProvider>. */
|
|
||||||
const useAuth = (): AuthContextValue => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useAuth must be used within an AuthProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { useAuth };
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -7,7 +6,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Box = ({ children, className }: Props) => (
|
const Box = ({ children, className }: Props) => (
|
||||||
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
|
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { ElementType } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
icon: ElementType;
|
|
||||||
title: string;
|
|
||||||
accent?: "primary" | "accent";
|
|
||||||
};
|
|
||||||
|
|
||||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: Props) => (
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${accent === "primary" ? "bg-terracotta/10" : "bg-olive/10"}`}>
|
|
||||||
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default CardHeader;
|
|
||||||
@@ -9,7 +9,7 @@ type DropdownItemProps = {
|
|||||||
|
|
||||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||||
<li
|
<li
|
||||||
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
|
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||||
onClick={() => onClick(item)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
|||||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(v => !v)}
|
onClick={() => setIsOpen(v => !v)}
|
||||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-background border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ type Props = {
|
|||||||
|
|
||||||
const Selector = ({ children, label }: Props) => (
|
const Selector = ({ children, label }: Props) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="w-full text-sm text-text-secondary">{label}</label>
|
<label className="w-full text-sm text-gray-600">{label}</label>
|
||||||
<div className="w-px h-5 text-text-secondary mx-2" />
|
<div className="w-px h-5 bg-gray-200 mx-2" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +1,48 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
|
user: User | null;
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
|
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
|
||||||
|
|
||||||
const Sidebar = ({className}: props) => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
|
const Sidebar = ({user, className}: props) => {
|
||||||
return (
|
return (
|
||||||
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
|
||||||
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
<div className="px-4 pt-5 pb-3">
|
||||||
|
<img
|
||||||
<nav className="flex flex-col flex-1 items-center w-3/4">
|
className="w-36 mx-auto"
|
||||||
<Divider />
|
src="../../public/logo.png"
|
||||||
|
alt="VoD System Logo"
|
||||||
<div className="flex flex-col gap-0.5 w-full">
|
/>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col flex-1 px-2 pb-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
||||||
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex flex-col gap-0.5 w-full">
|
<>
|
||||||
<div>
|
<Divider />
|
||||||
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className={"w-full"}>
|
{user && (
|
||||||
{user && (
|
<>
|
||||||
<>
|
<Divider />
|
||||||
<Divider />
|
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
|
import { login, logout } from "../utils/endpoints.ts";
|
||||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||||
import { GoogleLogin } from '@react-oauth/google';
|
import { GoogleLogin } from '@react-oauth/google';
|
||||||
|
|
||||||
|
import type { User } from "../utils/types.ts";
|
||||||
import type { CredentialResponse } from '@react-oauth/google';
|
import type { CredentialResponse } from '@react-oauth/google';
|
||||||
|
|
||||||
import MenuButton from "./buttons/MenuButton.tsx";
|
import MenuButton from "./buttons/MenuButton.tsx";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
|
||||||
import { useTheme } from "../theme/useTheme.ts";
|
|
||||||
|
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
sidebarToggled: boolean;
|
sidebarToggled: boolean;
|
||||||
setSidebarToggled: (toggled: boolean) => void;
|
setSidebarToggled: (toggled: boolean) => void;
|
||||||
|
user: User | null;
|
||||||
|
fetchUser: () => void;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -21,12 +23,12 @@ type props = {
|
|||||||
const Topbar = ({
|
const Topbar = ({
|
||||||
sidebarToggled,
|
sidebarToggled,
|
||||||
setSidebarToggled,
|
setSidebarToggled,
|
||||||
|
user,
|
||||||
|
fetchUser,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
className}: props) => {
|
className}: props) => {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, login, logout } = useAuth();
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
|
||||||
|
|
||||||
const handleLogin = (response: CredentialResponse) => {
|
const handleLogin = (response: CredentialResponse) => {
|
||||||
if (!response.credential) {
|
if (!response.credential) {
|
||||||
@@ -35,38 +37,34 @@ const Topbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
login(response.credential)
|
login(response.credential)
|
||||||
|
.then(() => {
|
||||||
|
fetchUser();
|
||||||
|
navigate(0);
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Login failed:", error);
|
console.error("Login failed:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
.then(() => navigate("/"))
|
.then(() => {
|
||||||
|
fetchUser();
|
||||||
|
navigate("/");
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
|
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
|
||||||
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
{ user ? (
|
||||||
<MenuButton
|
<div>
|
||||||
onClick={toggleTheme}
|
|
||||||
title={themeLabel}
|
|
||||||
aria-label={themeLabel}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
{ user ? (
|
|
||||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
|
||||||
<img
|
<img
|
||||||
className={"w-8 h-8 rounded-full inline-block"}
|
className={"w-8 h-8 rounded-full inline-block"}
|
||||||
src={user.profilePictureUrl}
|
src={user.profilePictureUrl}
|
||||||
@@ -78,8 +76,8 @@ const Topbar = ({
|
|||||||
{user.name}
|
{user.name}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -88,18 +86,16 @@ const Topbar = ({
|
|||||||
onClick={() => navigate("/profile")} />
|
onClick={() => navigate("/profile")} />
|
||||||
<DropdownItem item="Logout"
|
<DropdownItem item="Logout"
|
||||||
onClick={() => handleLogout()}
|
onClick={() => handleLogout()}
|
||||||
className={"text-error font-medium"} />
|
className={"text-red-500 font-medium"} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
) :
|
) :
|
||||||
(
|
(
|
||||||
<GoogleLogin
|
<GoogleLogin
|
||||||
shape={"pill"}
|
shape={"pill"}
|
||||||
theme={theme === "dark" ? "filled_black" : "outline"}
|
|
||||||
useOneTap={false}
|
useOneTap={false}
|
||||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
14
frontend/src/components/buttons/BlueButton.tsx
Normal file
14
frontend/src/components/buttons/BlueButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlueButton;
|
||||||
@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
|
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const PrimaryButton: React.FC<Props> = ({ className = "", ...props }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`bg-terracotta text-on-accent font-body text-sm rounded-lg hover:bg-terracotta-hover disabled:bg-terracotta-disabled transition-colors duration-150 h-10 px-4 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PrimaryButton;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import MenuButton from "./MenuButton.tsx";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -13,17 +13,11 @@ const SidebarButton = ({url, logo, label}: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link className="w-full" to={url}>
|
<Link className="w-full" to={url}>
|
||||||
<button className={`flex items-center gap-2.5 w-full text-sm
|
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
|
||||||
font-body p-2 hover:bg-hover hover:text-text-strong
|
|
||||||
rounded-md
|
|
||||||
${isActive ?
|
|
||||||
"bg-card text-text-strong font-medium shadow-sm"
|
|
||||||
:
|
|
||||||
"text-text-primary"}`}>
|
|
||||||
{logo}{label}
|
{logo}{label}
|
||||||
</button>
|
</MenuButton>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SidebarButton;
|
export default SidebarButton;
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Bookmark, Plus, X } from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Box from "../Box.tsx";
|
|
||||||
import CardHeader from "../CardHeader.tsx";
|
|
||||||
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
|
||||||
import type { Marker } from "../../utils/types.ts";
|
|
||||||
import { formatLocalTime } from "../../utils/utils.ts";
|
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
|
||||||
|
|
||||||
type RailSegment = "none" | "plain" | "lit";
|
|
||||||
|
|
||||||
const railClass = (segment: RailSegment) => clsx(
|
|
||||||
"w-px",
|
|
||||||
segment === "lit" && "bg-terracotta",
|
|
||||||
segment === "plain" && "bg-inactive-strong/60",
|
|
||||||
segment === "none" && "bg-transparent"
|
|
||||||
);
|
|
||||||
|
|
||||||
type MarkerRowProps = {
|
|
||||||
marker: Marker;
|
|
||||||
role: "start" | "end" | "between" | null;
|
|
||||||
/** Rail segments above and below the dot — "lit" inside the chosen section. */
|
|
||||||
railAbove: RailSegment;
|
|
||||||
railBelow: RailSegment;
|
|
||||||
onSelect: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MarkerRow = ({ marker, role, railAbove, railBelow, onSelect, onDelete }: MarkerRowProps) => (
|
|
||||||
<li className="group relative flex gap-3">
|
|
||||||
{/* Timeline rail: a dot per marker, with the segment between the chosen
|
|
||||||
start and end lit so the selected section reads as one run. */}
|
|
||||||
<div className="flex w-2.5 flex-col items-center flex-shrink-0">
|
|
||||||
<span className={clsx("h-3.5 flex-shrink-0", railClass(railAbove))} />
|
|
||||||
<span className={clsx(
|
|
||||||
"h-2.5 w-2.5 rounded-full border-2 flex-shrink-0 transition-colors duration-150",
|
|
||||||
role === "start" && "border-terracotta bg-terracotta",
|
|
||||||
role === "end" && "border-olive bg-olive",
|
|
||||||
role === "between" && "border-terracotta bg-card",
|
|
||||||
role === null && "border-inactive-strong bg-card"
|
|
||||||
)} />
|
|
||||||
<span className={clsx("flex-1", railClass(railBelow))} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The rail spans the full row, so the breathing room goes on the button —
|
|
||||||
the line stays continuous while neighbouring rings no longer touch. */}
|
|
||||||
<button
|
|
||||||
onClick={onSelect}
|
|
||||||
className={clsx(
|
|
||||||
"min-w-0 flex-1 rounded-md my-0.5 py-2 pl-2.5 pr-7 text-left transition-colors duration-150",
|
|
||||||
role === "start" && "bg-selected ring-1 ring-terracotta",
|
|
||||||
role === "end" && "bg-selected ring-1 ring-olive",
|
|
||||||
(role === "between" || role === null) && "hover:bg-hover"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="truncate text-sm font-medium text-text-strong">{marker.message}</p>
|
|
||||||
<p className="font-data text-[11px] text-muted tabular-nums">
|
|
||||||
{formatLocalTime(marker.timestamp)}
|
|
||||||
{role === "start" && <span className="font-medium text-terracotta"> · start</span>}
|
|
||||||
{role === "end" && <span className="font-medium text-olive"> · end</span>}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
title="Delete marker"
|
|
||||||
aria-label={`Delete marker ${marker.message}`}
|
|
||||||
className="absolute right-1 top-2.5 flex h-5 w-5 items-center justify-center rounded-md text-muted opacity-0 transition duration-150 hover:bg-hover hover:text-error focus-visible:opacity-100 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/** Newest first — the list renders them in the order given. */
|
|
||||||
markers: Marker[];
|
|
||||||
startMarkerId: string;
|
|
||||||
endMarkerId: string;
|
|
||||||
error: string | null;
|
|
||||||
/** Resolves true once the marker exists, so the input only clears on success. */
|
|
||||||
onAdd: (message: string) => Promise<boolean>;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onDelete: (id: number) => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MarkerList = ({ markers, startMarkerId, endMarkerId, error, onAdd, onSelect, onDelete, className }: Props) => {
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!message.trim() || busy) return;
|
|
||||||
setBusy(true);
|
|
||||||
if (await onAdd(message.trim())) setMessage("");
|
|
||||||
setBusy(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Either of the two may have been picked first, so the lit run is bounded by
|
|
||||||
// whichever indices come first/last rather than by start/end.
|
|
||||||
const startIndex = markers.findIndex((m) => String(m.id) === startMarkerId);
|
|
||||||
const endIndex = markers.findIndex((m) => String(m.id) === endMarkerId);
|
|
||||||
const hasRange = startIndex !== -1 && endIndex !== -1;
|
|
||||||
const rangeFrom = hasRange ? Math.min(startIndex, endIndex) : -1;
|
|
||||||
const rangeTo = hasRange ? Math.max(startIndex, endIndex) : -1;
|
|
||||||
// The rail segment sitting between marker `i` and the one after it.
|
|
||||||
const railSegment = (i: number): RailSegment => {
|
|
||||||
if (i < 0 || i >= markers.length - 1) return "none";
|
|
||||||
return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={clsx("p-5 flex flex-col gap-4", className)}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
|
|
||||||
{markers.length > 0 && (
|
|
||||||
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
||||||
placeholder="What just happened?"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={busy || !message.trim()}
|
|
||||||
className="flex items-center gap-1.5 px-3 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Plus size={15} />
|
|
||||||
Add
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{markers.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">
|
|
||||||
Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
/* py-0.5 keeps the scroll container from clipping the selection
|
|
||||||
ring on the first and last rows. */
|
|
||||||
<ul className="-mr-1 flex max-h-[26rem] flex-col overflow-y-auto py-0.5 pr-1">
|
|
||||||
{markers.map((m, i) => (
|
|
||||||
<MarkerRow
|
|
||||||
key={m.id}
|
|
||||||
marker={m}
|
|
||||||
role={String(m.id) === startMarkerId ? "start"
|
|
||||||
: String(m.id) === endMarkerId ? "end"
|
|
||||||
: hasRange && i > rangeFrom && i < rangeTo ? "between"
|
|
||||||
: null}
|
|
||||||
railAbove={railSegment(i - 1)}
|
|
||||||
railBelow={railSegment(i)}
|
|
||||||
onSelect={() => onSelect(m.id)}
|
|
||||||
onDelete={() => onDelete(m.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MarkerList;
|
|
||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-card rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -26,7 +26,7 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label="Resolution">
|
<Selector label="Resolution">
|
||||||
<select id="resolution" name="resolution" defaultValue="1280,720"
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
|
|||||||
@@ -1,47 +1,30 @@
|
|||||||
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
import BlueButton from "../buttons/BlueButton.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
downloadable: boolean;
|
downloadable: boolean;
|
||||||
progress: number;
|
progress: number
|
||||||
uploading: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Spinner = () => (
|
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
|
||||||
<svg
|
|
||||||
className="animate-spin h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-3"}>
|
<div className={"flex flex-col gap-3"}>
|
||||||
<PrimaryButton
|
<BlueButton
|
||||||
onClick={dataSend}
|
onClick={dataSend}>
|
||||||
disabled={uploading}
|
Export
|
||||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
</BlueButton>
|
||||||
>
|
|
||||||
{uploading && <Spinner />}
|
|
||||||
{uploading ? "Uploading..." : "Export"}
|
|
||||||
</PrimaryButton>
|
|
||||||
|
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
{ downloadable ?
|
{ downloadable ?
|
||||||
(<PrimaryButton className="w-full" onClick={handleDownload}>
|
(<BlueButton className="w-full" onClick={handleDownload}>
|
||||||
Download
|
Download
|
||||||
</PrimaryButton>)
|
</BlueButton>)
|
||||||
:(
|
:(
|
||||||
<progress
|
<progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className="w-full h-1.5 rounded bg-fields">
|
className="w-full h-1.5 rounded bg-gray-200">
|
||||||
</progress>
|
</progress>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,4 +33,4 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploadi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ExportWidget;
|
export default ExportWidget;
|
||||||
@@ -9,15 +9,12 @@ type Props = {
|
|||||||
|
|
||||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
<p className="w-full font-body text-text-strong font-bold text-sm">
|
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||||
Title <span className="text-error">*</span>
|
|
||||||
</p>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
placeholder="Enter title"
|
placeholder="Enter title"
|
||||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
|
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
|
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
<span className="font-data text-sm text-text-secondary tabular-nums">
|
<span>
|
||||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { groupByTimeRange } from "../../utils/utils";
|
|
||||||
|
|
||||||
type props<T> = {
|
|
||||||
items: T[];
|
|
||||||
getDate: (item: T) => string;
|
|
||||||
renderItem: (item: T) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TimeGroupedGrid = <T,>({ items, getDate, renderItem }: props<T>) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{groupByTimeRange(items, getDate).map((group) => (
|
|
||||||
<section key={group.label}>
|
|
||||||
<h2 className="text-xl font-heading text-text-primary mb-3">{group.label}</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
||||||
{group.items.map(renderItem)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TimeGroupedGrid;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
|
||||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
import {config} from "../../config.ts";
|
|
||||||
|
|
||||||
type VideoCardProps = {
|
type VideoCardProps = {
|
||||||
id: number,
|
id: number,
|
||||||
@@ -13,21 +13,17 @@ type VideoCardProps = {
|
|||||||
onEdit?: () => void,
|
onEdit?: () => void,
|
||||||
onDelete?: () => void,
|
onDelete?: () => void,
|
||||||
className?: string,
|
className?: string,
|
||||||
mediaApiPath?: string,
|
|
||||||
playerPath?: string,
|
|
||||||
itemLabel?: string,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackThumbnail = "public/default_thumbnail.png";
|
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||||
const API_URL = config.apiUrl;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
|
||||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -36,6 +32,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [createdAt]);
|
}, [createdAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isThumbnailAvailable(id)
|
||||||
|
.then(setThumbnailAvailable)
|
||||||
|
.catch(() => setThumbnailAvailable(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -55,17 +57,16 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`${playerPath}/${id}`}>
|
<Link to={"/video/" + id}>
|
||||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
<div className="relative overflow-hidden rounded-lg">
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
|
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
||||||
onError={() => setImgFailed(true)}
|
|
||||||
alt="Video Thumbnail"
|
alt="Video Thumbnail"
|
||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="absolute top-1.5 left-1.5 bg-media-backdrop/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -77,18 +78,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-media-backdrop/75 hover:bg-media-backdrop/90 text-on-dark rounded p-0.5 transition-colors"
|
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
||||||
{!confirmDelete && (<>
|
{!confirmDelete && (<>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onEdit)}
|
onClick={(e) => stopAndRun(e, onEdit)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<Pencil size={13} /> Edit
|
<Pencil size={13} /> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -96,7 +97,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} /> Delete
|
<Trash2 size={13} /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -104,17 +105,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
|
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||||
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
|
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||||
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
|
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -128,10 +129,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col p-2">
|
<div className="flex flex-col p-2">
|
||||||
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
|
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||||
{title === "" ? "(No Title)" : title}
|
{title === "" ? "(No Title)" : title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
|
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const config = {
|
|
||||||
apiUrl: window._env_?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
|
|
||||||
frontendUrl: window._env_?.VITE_FRONTEND_URL ?? import.meta.env.VITE_FRONTEND_URL ?? '',
|
|
||||||
googleClientId: window._env_?.VITE_GOOGLE_CLIENT_ID ?? import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '',
|
|
||||||
};
|
|
||||||
@@ -1,170 +1,45 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "flowbite-react/plugin/tailwindcss";
|
||||||
/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
|
@source "../.flowbite-react/class-list.json";
|
||||||
instead of Tailwind's default prefers-color-scheme. Without this a stray
|
|
||||||
`dark:` utility would disagree with an explicit light/dark choice. */
|
|
||||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
|
||||||
--font-data: "IBM Plex Mono", monospace;
|
|
||||||
|
|
||||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
/* Breakpoints */
|
||||||
--color-background: #F7F4EE;
|
--breakpoint-3xl: 1920px;
|
||||||
--color-sidebar: #F2EEE6;
|
|
||||||
--color-card: #FDFBF7;
|
|
||||||
--color-fields: #F4F0E8;
|
|
||||||
--color-dropzone: #F9F6EF;
|
|
||||||
|
|
||||||
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
|
--color-primary: oklch(0.55 0.21 254);
|
||||||
not an ink colour — so `text-on-dark` always reads on top of it. */
|
--color-primary-pressed: oklch(0.45 0.21 254);
|
||||||
--color-media-backdrop: #2B2620;
|
--color-secondary: oklch(0.94 0.01 250);
|
||||||
|
--color-accent: oklch(0.68 0.20 288);
|
||||||
|
--color-text: oklch(0.17 0.01 270);
|
||||||
|
--color-background: oklch(0.97 0.005 250);
|
||||||
|
--color-surface: oklch(1 0 0);
|
||||||
|
--color-border: oklch(0.91 0.008 250);
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
/* Easing */
|
||||||
--color-text-primary: #2B2620;
|
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||||
--color-text-strong: #35302A;
|
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||||
--color-text-body: #554D44;
|
|
||||||
--color-text-secondary: #6E6559;
|
|
||||||
--color-muted: #9A9084;
|
|
||||||
--color-faint-mono: #A79C8E;
|
|
||||||
|
|
||||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
|
||||||
--color-terracotta: #A85A38;
|
|
||||||
--color-terracotta-hover: #94492A;
|
|
||||||
--color-terracotta-light: #C87A52;
|
|
||||||
--color-terracotta-disabled: #C9AE9C;
|
|
||||||
--color-olive: #6E7A50;
|
|
||||||
|
|
||||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
|
||||||
--color-live: #B5432E;
|
|
||||||
--color-error: #B5432E;
|
|
||||||
|
|
||||||
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
|
|
||||||
--color-on-dark: #FBF8F2;
|
|
||||||
--color-on-accent: #FDFBF7;
|
|
||||||
|
|
||||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
|
||||||
--color-hairline: #F0EBE2;
|
|
||||||
--color-hover: #E7E0D2;
|
|
||||||
--color-selected: #F6EFE6;
|
|
||||||
--color-ring: #E2DACB;
|
|
||||||
--color-inactive: #DED7CA;
|
|
||||||
--color-inactive-strong: #C4BAA9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode. Only token values change here: every component already styles
|
#range-slider .range-slider__range {
|
||||||
itself with the semantic utilities above, so the whole UI follows from this
|
background: var(--color-primary);
|
||||||
one block. `--color-on-dark` is deliberately absent — it is the ink on the
|
|
||||||
media backdrop, which stays dark in both themes.
|
|
||||||
|
|
||||||
Soft charcoal rather than pure black, with only a trace of warmth so it
|
|
||||||
still sits with the paper tones of the light theme. Ink is a warm off-white
|
|
||||||
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
|
|
||||||
light theme: sidebar recessed, cards raised, fields inset into the card. */
|
|
||||||
:root[data-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
|
|
||||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
|
||||||
--color-background: #161615;
|
|
||||||
--color-sidebar: #111111;
|
|
||||||
--color-card: #1F1F1E;
|
|
||||||
--color-fields: #1A1A19;
|
|
||||||
--color-dropzone: #1C1C1B;
|
|
||||||
|
|
||||||
--color-media-backdrop: #0B0B0B;
|
|
||||||
|
|
||||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
|
||||||
--color-text-primary: #F5F4EF;
|
|
||||||
--color-text-strong: #E8E6E0;
|
|
||||||
--color-text-body: #C9C7C0;
|
|
||||||
--color-text-secondary: #A2A099;
|
|
||||||
--color-muted: #87857F;
|
|
||||||
--color-faint-mono: #6C6B66;
|
|
||||||
|
|
||||||
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */
|
|
||||||
--color-terracotta: #D97757;
|
|
||||||
--color-terracotta-hover: #E28B6E;
|
|
||||||
--color-terracotta-light: #BC6A4E;
|
|
||||||
--color-terracotta-disabled: #46372F;
|
|
||||||
--color-olive: #A7AF84;
|
|
||||||
|
|
||||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
|
||||||
--color-live: #E0644E;
|
|
||||||
--color-error: #E0644E;
|
|
||||||
|
|
||||||
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
|
|
||||||
sits on them flips dark ─────────────────────────────────────────────── */
|
|
||||||
--color-on-accent: #1A1410;
|
|
||||||
|
|
||||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
|
||||||
--color-hairline: #282827;
|
|
||||||
--color-hover: #2A2A29;
|
|
||||||
--color-selected: #2A2522;
|
|
||||||
--color-ring: #3B3B3A;
|
|
||||||
--color-inactive: #333332;
|
|
||||||
--color-inactive-strong: #5A5955;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
|
#range-slider .range-slider__thumb {
|
||||||
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
|
background: var(--color-primary);
|
||||||
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
|
|
||||||
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
|
|
||||||
--tw-shadow-color: rgb(0 0 0 / 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color-scheme: light;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
|
||||||
color: var(--color-text-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility font-heading {
|
|
||||||
font-family: "Newsreader", serif;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Google Identity Services renders its sign-in button in a cross-origin iframe
|
|
||||||
whose document is light-scheme with a transparent background. Left to inherit
|
|
||||||
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
|
|
||||||
white canvas behind it instead — framing the pill in a white box on every dark
|
|
||||||
page. Pinning the iframe back to light keeps it transparent; the button's own
|
|
||||||
colours come from the `theme` prop in Topbar.tsx. */
|
|
||||||
iframe[src*="accounts.google.com/gsi/"] {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
|
||||||
defaults; the #id beats those class selectors regardless of import order. */
|
|
||||||
#range-slider {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--color-fields);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__range {
|
|
||||||
background: var(--color-terracotta-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__thumb {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--color-terracotta);
|
|
||||||
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#range-slider .range-slider__thumb:focus-visible {
|
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,26 @@ import Sidebar from '../components/Sidebar'
|
|||||||
import Topbar from '../components/Topbar'
|
import Topbar from '../components/Topbar'
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { useAuth } from "../auth/useAuth";
|
import type {User} from "../utils/types";
|
||||||
import { getStreamStatus } from "../utils/api/stream";
|
import { getUser, getStreamStatus } from "../utils/endpoints";
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||||
|
const [user, setUser] = useState<null | User>(null);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const { user } = useAuth();
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await getUser();
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -20,13 +33,16 @@ const MainLayout = () => {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
|
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
user={user}
|
||||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||||
<Topbar
|
<Topbar
|
||||||
className={"transition-all duration-300"}
|
className={"transition-all duration-300"}
|
||||||
sidebarToggled={sidebarToggled}
|
sidebarToggled={sidebarToggled}
|
||||||
setSidebarToggled={setSidebarToggled}
|
setSidebarToggled={setSidebarToggled}
|
||||||
|
user={user}
|
||||||
|
fetchUser={fetchUser}
|
||||||
isStreaming={isStreaming}/>
|
isStreaming={isStreaming}/>
|
||||||
<div className="overflow-auto bg-background">
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -34,4 +50,4 @@ const MainLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default MainLayout;
|
export default MainLayout;
|
||||||
@@ -5,12 +5,13 @@ import PlaybackSlider from "./../components/video/PlaybackSlider";
|
|||||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
||||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||||
import { compress } from "../utils/api/media"
|
import { compress, getJob } from "../utils/endpoints"
|
||||||
import { getJob, downloadJob } from "../utils/api/jobs"
|
|
||||||
import type { VideoMetadata } from "../utils/types.ts";
|
import type { VideoMetadata } from "../utils/types.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
const ClipEdit = () => {
|
const ClipEdit = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const localFile: File = location.state?.file;
|
const localFile: File = location.state?.file;
|
||||||
@@ -33,7 +34,6 @@ const ClipEdit = () => {
|
|||||||
});
|
});
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [downloadable, setDownloadable] = useState(false);
|
const [downloadable, setDownloadable] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -59,31 +59,21 @@ const ClipEdit = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sendData = async () => {
|
const sendData = async () => {
|
||||||
if (!localFile || uploading) return;
|
if (!localFile) return;
|
||||||
|
|
||||||
const title = outputMetadata.title.trim();
|
|
||||||
if (!title) {
|
|
||||||
setError('A title is required before exporting.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
let jobId: string;
|
let jobId: string;
|
||||||
try {
|
try {
|
||||||
jobId = await compress(localFile, { ...outputMetadata, title });
|
jobId = await compress(localFile, outputMetadata);
|
||||||
setUploadedId(jobId);
|
setUploadedId(jobId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
setUploading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
const pollProgress = async (jobId: string, intervalId: number) => {
|
||||||
getJob(jobId)
|
getJob(jobId)
|
||||||
.then((job) => {
|
.then((job) => {
|
||||||
setProgress(job.progress);
|
setProgress(job.progress);
|
||||||
@@ -91,31 +81,28 @@ const ClipEdit = () => {
|
|||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setUploading(false);
|
|
||||||
} else if (job.isComplete) {
|
} else if (job.isComplete) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setDownloadable(true);
|
setDownloadable(true);
|
||||||
setUploading(false);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
setError(`Failed to fetch progress: ${err.message}`);
|
setError(`Failed to fetch progress: ${err.message}`);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
setUploading(false);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!uploadedId) return;
|
if (!uploadedId) return;
|
||||||
|
|
||||||
let blob: Blob;
|
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
|
||||||
try {
|
|
||||||
blob = await downloadJob(uploadedId);
|
if (!response.ok) {
|
||||||
} catch (err) {
|
console.error('Download failed');
|
||||||
console.error('Download failed', err);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|
||||||
@@ -132,7 +119,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
|
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -146,7 +133,7 @@ const ClipEdit = () => {
|
|||||||
<Playbar
|
<Playbar
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
className={"w-full accent-terracotta"}
|
className={"w-full accent-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaybackSlider
|
<PlaybackSlider
|
||||||
@@ -154,7 +141,7 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
sliderValue={playbackValue}
|
sliderValue={playbackValue}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
className={"w-full accent-terracotta"}
|
className={"w-full accent-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClipRangeSlider
|
<ClipRangeSlider
|
||||||
@@ -162,11 +149,11 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
setMetadata={setOutputMetadata}
|
setMetadata={setOutputMetadata}
|
||||||
className={"w-full mt-2"}
|
className={"w-full mt-2 bg-primary"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={"text-error text-center mt-2"}>
|
<div className={"text-red-600 text-center mt-2"}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +166,6 @@ const ClipEdit = () => {
|
|||||||
handleDownload={handleDownload}
|
handleDownload={handleDownload}
|
||||||
downloadable={downloadable}
|
downloadable={downloadable}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
uploading={uploading}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
|
|
||||||
const ClipUpload = () => {
|
const ClipUpload = () => {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [error, setError] = useState<null | string>(null);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<null | string>(null);
|
||||||
|
|
||||||
const press = () => {
|
const press = () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -16,40 +17,23 @@ const ClipUpload = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center p-8">
|
<div className="flex items-start justify-center p-8 min-h-full">
|
||||||
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||||
<div
|
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||||
className="cursor-pointer rounded-lg border-2
|
|
||||||
border-dashed border-hairline bg-dropzone
|
|
||||||
text-center text-sm text-text-secondary
|
|
||||||
hover:bg-hover transition-colors duration-150
|
|
||||||
min-h-50 flex flex-col items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<label className="cursor-pointer flex flex-col items-center justify-center gap-2 w-full h-full">
|
|
||||||
Choose file
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => {
|
|
||||||
const selected = e.target.files?.[0] ?? null;
|
|
||||||
setFile(selected);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{file && (
|
|
||||||
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<input
|
||||||
onClick={press}
|
type="file"
|
||||||
className={"hover:bg-hover p-1 rounded-md"}
|
onChange={(e) => {
|
||||||
>
|
const selected = e.target.files?.[0] ?? null;
|
||||||
Upload
|
setFile(selected);
|
||||||
</button>
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
|
||||||
|
/>
|
||||||
|
|
||||||
{error && <p className="text-sm font-body text-center">{error}</p>}
|
<BlueButton onClick={press}>Upload</BlueButton>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
import { getClipById, patchClip } from "../utils/endpoints.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
const EditClip = () => {
|
const EditClip = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -50,20 +50,20 @@ const EditClip = () => {
|
|||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-muted text-sm">Loading...</div>
|
<div className="text-gray-400 text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Title</label>
|
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -74,7 +74,7 @@ const EditClip = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-text-secondary">Description</label>
|
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -84,17 +84,17 @@ const EditClip = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
: "Save"}
|
: "Save"}
|
||||||
</PrimaryButton>
|
</BlueButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, Check } from "lucide-react";
|
|
||||||
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
|
||||||
import Box from "../components/Box.tsx";
|
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
|
||||||
|
|
||||||
const EditVod = () => {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
getVodById(id)
|
|
||||||
.then((vod) => {
|
|
||||||
if (vod) {
|
|
||||||
setTitle(vod.title ?? "");
|
|
||||||
setDescription(vod.description ?? "");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setError("Failed to load vod."))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!id) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await patchVod(Number(id), { title, description });
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to save.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-muted text-sm">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium text-text-secondary">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Enter title"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium text-text-secondary">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Enter description"
|
|
||||||
rows={4}
|
|
||||||
className={inputClass + " resize-none"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-error">{error}</p>}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
|
||||||
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
|
||||||
{saving ? "Saving…" : saved
|
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
|
||||||
: "Save"}
|
|
||||||
</PrimaryButton>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(-1)}
|
|
||||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditVod;
|
|
||||||
@@ -43,10 +43,10 @@ const Home = () => {
|
|||||||
alt="VoD System Logo"
|
alt="VoD System Logo"
|
||||||
className="h-24 mb-6 object-contain"
|
className="h-24 mb-6 object-contain"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-4xl font-heading text-text-primary">
|
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
|
||||||
A home for your clips.
|
A home for your clips.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-secondary">
|
<p className="text-base text-gray-500 leading-relaxed">
|
||||||
Upload, trim, and export video clips. Stream live if you need to.
|
Upload, trim, and export video clips. Stream live if you need to.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,21 +56,21 @@ const Home = () => {
|
|||||||
{features.map(({ icon: Icon, title, description }) => (
|
{features.map(({ icon: Icon, title, description }) => (
|
||||||
<div
|
<div
|
||||||
key={title}
|
key={title}
|
||||||
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Icon size={18} className="text-terracotta" />
|
<Icon size={18} className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
|
||||||
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
|
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Format note */}
|
{/* Format note */}
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-gray-400">
|
||||||
Supports MP4, MOV, WebM, and OGG.
|
Supports MP4, MOV, WebM, and OGG.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
import VideoCard from "../components/video/VideoCard";
|
||||||
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getClips, deleteClip } from "../utils/api/clips";
|
import { getClips, deleteClip } from "../utils/endpoints";
|
||||||
import { stringToDate } from "../utils/utils";
|
|
||||||
import type { Clip } from "../utils/types";
|
import type { Clip } from "../utils/types";
|
||||||
|
|
||||||
const MyClips = () => {
|
const MyClips = () => {
|
||||||
const [clips, setClips] = useState<Clip[]>([]);
|
const [clips, setClips] = useState<Clip[]>([]);
|
||||||
const [error, setError] = useState<null | string>(null);
|
const [error, setError] = useState<null | string>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getClips()
|
getClips()
|
||||||
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
|
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||||
.catch((err) => setError(err))
|
.catch((err) => setError(err));
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
@@ -30,12 +26,10 @@ const MyClips = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||||
|
|
||||||
<TimeGroupedGrid
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
items={clips}
|
{clips.map((clip) => (
|
||||||
getDate={(clip) => clip.createdAt}
|
|
||||||
renderItem={(clip) => (
|
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
id={clip.id}
|
id={clip.id}
|
||||||
@@ -45,17 +39,17 @@ const MyClips = () => {
|
|||||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||||
onDelete={() => handleDelete(clip.id)}
|
onDelete={() => handleDelete(clip.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 && !error && !loading && (
|
{clips.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
<p className="text-base">No clips yet.</p>
|
<p className="text-base">No clips yet.</p>
|
||||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
|
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import VideoCard from "../components/video/VideoCard";
|
|
||||||
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { getVods, deleteVod } from "../utils/api/vods";
|
|
||||||
import { stringToDate } from "../utils/utils";
|
|
||||||
import type { Vod } from "../utils/types";
|
|
||||||
|
|
||||||
const MyVods = () => {
|
|
||||||
const [vods, setVods] = useState<Vod[]>([]);
|
|
||||||
const [error, setError] = useState<null | string>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getVods()
|
|
||||||
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
|
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await deleteVod(id);
|
|
||||||
setVods(prev => prev.filter(v => v.id !== id));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete vod");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
|
|
||||||
|
|
||||||
<TimeGroupedGrid
|
|
||||||
items={vods}
|
|
||||||
getDate={(vod) => vod.createdAt}
|
|
||||||
renderItem={(vod) => (
|
|
||||||
<VideoCard
|
|
||||||
key={vod.id}
|
|
||||||
id={vod.id}
|
|
||||||
title={vod.title}
|
|
||||||
duration={vod.duration}
|
|
||||||
createdAt={vod.createdAt}
|
|
||||||
mediaApiPath="/api/v1/vods"
|
|
||||||
playerPath="/vod"
|
|
||||||
itemLabel="vod"
|
|
||||||
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
|
|
||||||
onDelete={() => handleDelete(vod.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{vods.length === 0 && !error && !loading && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
|
||||||
<p className="text-base">No VoDs yet.</p>
|
|
||||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-error mt-4">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MyVods;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Lock } from 'lucide-react';
|
|
||||||
import {useNavigate} from "react-router-dom";
|
|
||||||
|
|
||||||
const NotFound = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const goHomePage = () => {
|
|
||||||
navigate("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
|
|
||||||
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
|
||||||
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
|
|
||||||
This page does not exist.
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-faint-mono/70 max-w-xs">
|
|
||||||
<a onClick={goHomePage} className={"font-bold cursor-pointer"}>Click here</a> to get back to the home page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
import { getUser } from "../utils/endpoints.ts";
|
||||||
|
import type { User } from "../utils/types.ts";
|
||||||
import { formatLocalDate } from "../utils/utils.ts";
|
import { formatLocalDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { user } = useAuth();
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [keyVisible, setKeyVisible] = useState(false);
|
const [keyVisible, setKeyVisible] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUser().then(setUser).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!user?.streamKey) return;
|
if (!user?.streamKey) return;
|
||||||
navigator.clipboard.writeText(user.streamKey);
|
navigator.clipboard.writeText(user.streamKey);
|
||||||
@@ -18,7 +23,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-muted text-base">
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
Please log in to view your profile.
|
Please log in to view your profile.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -26,7 +31,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||||
|
|
||||||
<Box className="p-6 mb-4 flex items-center gap-5">
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
@@ -36,31 +41,31 @@ const Profile = () => {
|
|||||||
className="w-20 h-20 rounded-full shadow-sm"
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
|
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
||||||
<p className="text-sm text-text-secondary">@{user.username}</p>
|
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||||
<p className="text-sm text-text-secondary">{user.email}</p>
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
<p className="text-xs text-muted mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
Member since {formatLocalDate(user.createdAt)}
|
Member since {formatLocalDate(user.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6">
|
<Box className="p-6">
|
||||||
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||||
<p className="text-sm text-text-secondary mb-4">
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||||
Keep it private — anyone with this key can stream to your account.
|
Keep it private — anyone with this key can stream to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden">
|
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
||||||
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setKeyVisible(v => !v)}
|
onClick={() => setKeyVisible(v => !v)}
|
||||||
title={keyVisible ? "Hide key" : "Show key"}
|
title={keyVisible ? "Hide key" : "Show key"}
|
||||||
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -68,9 +73,9 @@ const Profile = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy key"
|
title="Copy key"
|
||||||
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
|
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import Box from "../components/Box.tsx";
|
|
||||||
import CardHeader from "../components/CardHeader.tsx";
|
|
||||||
import MarkerList from "../components/stream/MarkerList.tsx";
|
|
||||||
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
|
||||||
import { useAuth } from "../auth/useAuth.ts";
|
|
||||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
|
||||||
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
|
||||||
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
|
||||||
import { getJob } from "../utils/api/jobs.ts";
|
|
||||||
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
|
||||||
import { formatLocalDate, stringToDate } from "../utils/utils.ts";
|
|
||||||
|
|
||||||
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
|
||||||
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
|
||||||
|
|
||||||
type TrackedJob = {
|
|
||||||
uuid: string;
|
|
||||||
label: string;
|
|
||||||
progress: number;
|
|
||||||
state: JobResponse['state'];
|
|
||||||
errorOutput: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
|
||||||
if (job.state === 'FAILED') {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<X size={15} className="text-error mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (job.state === 'SUCCEEDED') {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Check size={15} className="text-olive flex-shrink-0" />
|
|
||||||
<p className="text-text-body">{job.label} saved</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 size={15} className="text-terracotta animate-spin flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-text-secondary mb-1">{job.label}…</p>
|
|
||||||
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StreamControl = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
|
|
||||||
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
|
||||||
|
|
||||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
|
||||||
const [markerError, setMarkerError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [clipDuration, setClipDuration] = useState(30);
|
|
||||||
const [clipTitle, setClipTitle] = useState("");
|
|
||||||
const [clipDescription, setClipDescription] = useState("");
|
|
||||||
const [clipError, setClipError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [startMarkerId, setStartMarkerId] = useState<string>("");
|
|
||||||
const [endMarkerId, setEndMarkerId] = useState<string>("");
|
|
||||||
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
|
|
||||||
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
|
|
||||||
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [jobs, setJobs] = useState<TrackedJob[]>([]);
|
|
||||||
|
|
||||||
const isStreaming = streamStatus?.isStreaming ?? false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
|
||||||
poll();
|
|
||||||
const interval = setInterval(poll, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user || !streamStatus?.id) {
|
|
||||||
setStreamDetails(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getStreamHistory(user.id)
|
|
||||||
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [user, streamStatus?.id]);
|
|
||||||
|
|
||||||
const refreshMarkers = () => {
|
|
||||||
getMarkers()
|
|
||||||
.then((data) => setMarkers(data
|
|
||||||
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isStreaming) {
|
|
||||||
setMarkers([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refreshMarkers();
|
|
||||||
const interval = setInterval(refreshMarkers, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isStreaming, streamStatus?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
|
|
||||||
if (pending.length === 0) return;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
pending.forEach((job) => {
|
|
||||||
getJob(job.uuid)
|
|
||||||
.then((result) => {
|
|
||||||
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
|
|
||||||
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
|
||||||
: j));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [jobs]);
|
|
||||||
|
|
||||||
const addJob = (uuid: string, label: string) => {
|
|
||||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMarker = async (message: string) => {
|
|
||||||
setMarkerError(null);
|
|
||||||
try {
|
|
||||||
await createMarker(message);
|
|
||||||
refreshMarkers();
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClip = async () => {
|
|
||||||
setClipError(null);
|
|
||||||
try {
|
|
||||||
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
|
|
||||||
addJob(uuid, `Clip (last ${clipDuration}s)`);
|
|
||||||
setClipTitle("");
|
|
||||||
setClipDescription("");
|
|
||||||
} catch (err) {
|
|
||||||
setClipError(err instanceof Error ? err.message : "Failed to clip section");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveByMarkers = async () => {
|
|
||||||
setMarkerSaveError(null);
|
|
||||||
if (!startMarkerId || !endMarkerId) {
|
|
||||||
setMarkerSaveError("Pick a start and an end marker from the timeline");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const uuid = await saveSectionByMarkers(
|
|
||||||
Number(startMarkerId),
|
|
||||||
Number(endMarkerId),
|
|
||||||
markerSaveTitle || undefined,
|
|
||||||
markerSaveDescription || undefined
|
|
||||||
);
|
|
||||||
addJob(uuid, "Save (by markers)");
|
|
||||||
setStartMarkerId("");
|
|
||||||
setEndMarkerId("");
|
|
||||||
setMarkerSaveTitle("");
|
|
||||||
setMarkerSaveDescription("");
|
|
||||||
} catch (err) {
|
|
||||||
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteMarker = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await deleteMarker(id);
|
|
||||||
if (startMarkerId === String(id)) setStartMarkerId("");
|
|
||||||
if (endMarkerId === String(id)) setEndMarkerId("");
|
|
||||||
refreshMarkers();
|
|
||||||
} catch (err) {
|
|
||||||
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectMarker = (id: number) => {
|
|
||||||
const idStr = String(id);
|
|
||||||
setMarkerSaveError(null);
|
|
||||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
|
||||||
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
|
|
||||||
if (!startMarkerId) { setStartMarkerId(idStr); return; }
|
|
||||||
if (!endMarkerId) { setEndMarkerId(idStr); return; }
|
|
||||||
setEndMarkerId(idStr);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-full text-muted text-base">
|
|
||||||
Please log in to control your stream.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
|
||||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
|
||||||
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
|
||||||
|
|
||||||
{/* Hero status bar */}
|
|
||||||
<Box className={clsx(
|
|
||||||
"p-6 flex items-center justify-between gap-6 border-l-4",
|
|
||||||
isStreaming ? "border-l-live" : "border-l-hairline"
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-3.5">
|
|
||||||
<span className="relative flex h-3.5 w-3.5">
|
|
||||||
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
|
|
||||||
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
|
||||||
{isStreaming && streamDetails?.startDate ? (
|
|
||||||
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
|
|
||||||
) : !isStreaming ? (
|
|
||||||
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isStreaming && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
|
|
||||||
{/* Marker timeline. First on narrow screens, where marking
|
|
||||||
something as it happens beats the action forms for urgency. */}
|
|
||||||
<MarkerList
|
|
||||||
className="lg:order-last lg:sticky lg:top-6"
|
|
||||||
markers={markers}
|
|
||||||
startMarkerId={startMarkerId}
|
|
||||||
endMarkerId={endMarkerId}
|
|
||||||
error={markerError}
|
|
||||||
onAdd={handleAddMarker}
|
|
||||||
onSelect={handleSelectMarker}
|
|
||||||
onDelete={handleDeleteMarker}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Forefront actions */}
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
|
||||||
{markers.length < 2 ? (
|
|
||||||
<p className="text-sm text-muted">Add at least two markers to save a section between them.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
|
||||||
startMarker ? "border-terracotta text-terracotta bg-selected" : "border-dashed border-inactive text-muted"
|
|
||||||
)}>
|
|
||||||
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
|
||||||
</span>
|
|
||||||
<span className="text-inactive-strong">→</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
|
||||||
endMarker ? "border-olive text-olive bg-selected" : "border-dashed border-inactive text-muted"
|
|
||||||
)}>
|
|
||||||
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
|
||||||
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
|
||||||
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className={labelClass}>Duration (seconds)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={clipDuration}
|
|
||||||
onChange={(e) => setClipDuration(Number(e.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
|
||||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
|
||||||
{clipError && <p className="text-sm text-error">{clipError}</p>}
|
|
||||||
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="p-5 flex flex-col gap-3">
|
|
||||||
<CardHeader icon={Activity} title="Recent actions" />
|
|
||||||
{jobs.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StreamControl;
|
|
||||||
@@ -1,27 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||||
import { AuthError } from "../utils/api/client.ts";
|
import type { Clip } from "../utils/types";
|
||||||
|
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
// Both VoDs and clips share the same on-screen shape.
|
const VideoPlayer = () => {
|
||||||
type MediaItem = {
|
|
||||||
title: string,
|
|
||||||
createdAt: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaPlayerProps = {
|
|
||||||
/** Human-readable name for the media, e.g. "VoD" or "clip". Used in messages. */
|
|
||||||
noun: string,
|
|
||||||
/** Builds the raw media stream URL for the given id. Loaded directly by the
|
|
||||||
* <video> element so the browser can issue HTTP Range requests. */
|
|
||||||
getMediaUrl: (id: string) => string,
|
|
||||||
/** Fetches the media metadata for the given id. */
|
|
||||||
fetchDetails: (id: string) => Promise<MediaItem | null>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -31,11 +16,10 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const mediaUrl = id ? getMediaUrl(id) : undefined;
|
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
const [notAuthenticated, setNotAuthenticated] = useState(false);
|
||||||
const [item, setItem] = useState<MediaItem | null>(null);
|
const [clip, setClip] = useState<Clip | null>(null);
|
||||||
const [timeAgo, setTimeAgo] = useState("");
|
const [timeAgo, setTimeAgo] = useState("");
|
||||||
|
|
||||||
// Playback
|
// Playback
|
||||||
@@ -55,28 +39,28 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// ── Data loading ────────────────────────────────────────────────────────
|
// ── Data loading ────────────────────────────────────────────────────────
|
||||||
// The video stream itself is loaded by the <video> element from `mediaUrl`,
|
|
||||||
// which lets the browser fetch it incrementally via Range requests. We only
|
|
||||||
// need to fetch the metadata here; it shares the media endpoint's auth, so a
|
|
||||||
// 401/403 here also stands in for the stream being inaccessible.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) { setError(`${noun} ID is required.`); return; }
|
if (!id) { setError("Clip ID is required."); return; }
|
||||||
|
|
||||||
fetchDetails(id)
|
getVideoBlob(id)
|
||||||
.then(setItem)
|
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
|
||||||
|
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
|
||||||
|
|
||||||
|
getClipById(id)
|
||||||
|
.then(setClip)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||||
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
else setError("Failed to load clip details.");
|
||||||
});
|
});
|
||||||
}, [id, noun, fetchDetails]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item?.createdAt) return;
|
if (!clip?.createdAt) return;
|
||||||
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
|
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
|
||||||
update();
|
update();
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [item]);
|
}, [clip]);
|
||||||
|
|
||||||
// ── Controls visibility ─────────────────────────────────────────────────
|
// ── Controls visibility ─────────────────────────────────────────────────
|
||||||
const scheduleHide = useCallback(() => {
|
const scheduleHide = useCallback(() => {
|
||||||
@@ -202,16 +186,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
if (notAuthenticated) {
|
if (notAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||||
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
|
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<Lock size={24} className="text-muted" />
|
<Lock size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-heading text-text-primary">Not authenticated</p>
|
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Go back
|
Go back
|
||||||
@@ -224,7 +208,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
<div className="h-full flex flex-col p-4 gap-3">
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
@@ -233,7 +217,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
onMouseMove={showControls}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
@@ -242,10 +226,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
src={mediaUrl}
|
src={videoUrl}
|
||||||
// Send auth cookies with the cross-origin Range requests.
|
|
||||||
crossOrigin="use-credentials"
|
|
||||||
preload="metadata"
|
|
||||||
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
|
||||||
onPause={() => {
|
onPause={() => {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -253,19 +234,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
}}
|
}}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
onLoadedMetadata={() => {
|
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
|
||||||
if (videoRef.current) setDuration(videoRef.current.duration);
|
|
||||||
setMediaLoaded(true);
|
|
||||||
}}
|
|
||||||
onWaiting={() => setIsWaiting(true)}
|
onWaiting={() => setIsWaiting(true)}
|
||||||
onCanPlay={() => setIsWaiting(false)}
|
onCanPlay={() => setIsWaiting(false)}
|
||||||
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Buffering spinner */}
|
{/* Buffering spinner */}
|
||||||
{isWaiting && mediaUrl && (
|
{isWaiting && videoUrl && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
|
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -282,20 +260,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Scrub bar */}
|
{/* Scrub bar */}
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 left-0 bg-on-dark/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||||
<div className="absolute inset-y-0 left-0 bg-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons row */}
|
{/* Buttons row */}
|
||||||
<div className="flex items-center gap-3 text-on-dark">
|
<div className="flex items-center gap-3 text-white">
|
||||||
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
||||||
{isPlaying
|
{isPlaying
|
||||||
? <Pause size={20} fill="currentColor" />
|
? <Pause size={20} fill="currentColor" />
|
||||||
: <Play size={20} fill="currentColor" />}
|
: <Play size={20} fill="currentColor" />}
|
||||||
@@ -303,7 +281,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
|
|
||||||
{/* Volume — slider expands on hover */}
|
{/* Volume — slider expands on hover */}
|
||||||
<div className="flex items-center gap-1.5 group/vol">
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
||||||
<VolumeIcon size={18} />
|
<VolumeIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||||
@@ -312,18 +290,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
min={0} max={1} step={0.02}
|
min={0} max={1} step={0.02}
|
||||||
value={effectiveVolume}
|
value={effectiveVolume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
className="w-16 accent-on-dark cursor-pointer"
|
className="w-16 accent-white cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs font-data tabular-nums text-on-dark/80">
|
<span className="text-xs font-mono tabular-nums text-white/80">
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
|
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
||||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,20 +309,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-error text-sm mt-3">{error}</div>}
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
{!mediaLoaded && !error && (
|
{!videoUrl && !error && (
|
||||||
<div className="text-muted text-sm mt-3 flex items-center gap-2">
|
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
|
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||||
Loading video...
|
Loading video...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box className="p-4 flex-shrink-0">
|
<Box className="p-4 flex-shrink-0">
|
||||||
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
|
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
||||||
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MediaPlayer;
|
export default VideoPlayer;
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
|
|
||||||
/** What the user chose. "system" defers to the OS setting. */
|
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
|
||||||
|
|
||||||
/** The theme actually on screen, once "system" has been resolved. */
|
|
||||||
type ResolvedTheme = "light" | "dark";
|
|
||||||
|
|
||||||
type ThemeContextValue = {
|
|
||||||
/** The stored choice, which may be "system". */
|
|
||||||
preference: ThemePreference;
|
|
||||||
/** The theme currently applied to the document. */
|
|
||||||
theme: ResolvedTheme;
|
|
||||||
/** Store a new preference and apply it immediately. */
|
|
||||||
setPreference: (preference: ThemePreference) => void;
|
|
||||||
/** Flip between light and dark, pinning the result as an explicit choice. */
|
|
||||||
toggleTheme: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
export { ThemeContext };
|
|
||||||
export type { ThemeContextValue, ThemePreference, ResolvedTheme };
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { ThemeContext } from "./ThemeContext.ts";
|
|
||||||
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
|
|
||||||
|
|
||||||
/** Shared with the pre-paint script in index.html — keep the two in step. */
|
|
||||||
const STORAGE_KEY = "theme";
|
|
||||||
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
||||||
|
|
||||||
const readPreference = (): ThemePreference => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Storage blocked (private browsing, cookies disabled) — follow the OS.
|
|
||||||
}
|
|
||||||
return "system";
|
|
||||||
};
|
|
||||||
|
|
||||||
const readSystemTheme = (): ResolvedTheme =>
|
|
||||||
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the single source of truth for the active theme. Nothing reads colours
|
|
||||||
* from here: the palette lives in index.css and switches on the `data-theme`
|
|
||||||
* attribute this provider writes, so components keep using the semantic
|
|
||||||
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
|
|
||||||
*/
|
|
||||||
const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
|
|
||||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
|
|
||||||
|
|
||||||
// Keep following the OS while the preference is "system".
|
|
||||||
useEffect(() => {
|
|
||||||
const query = window.matchMedia(DARK_QUERY);
|
|
||||||
const onChange = (event: MediaQueryListEvent) =>
|
|
||||||
setSystemTheme(event.matches ? "dark" : "light");
|
|
||||||
|
|
||||||
query.addEventListener("change", onChange);
|
|
||||||
return () => query.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.dataset.theme = theme;
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const setPreference = useCallback((next: ThemePreference) => {
|
|
||||||
setPreferenceState(next);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// Storage blocked — the choice still applies for this session.
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
|
||||||
setPreference(theme === "dark" ? "light" : "dark");
|
|
||||||
}, [theme, setPreference]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeProvider;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { ThemeContext } from "./ThemeContext.ts";
|
|
||||||
import type { ThemeContextValue } from "./ThemeContext.ts";
|
|
||||||
|
|
||||||
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
|
|
||||||
const useTheme = (): ThemeContextValue => {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { useTheme };
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {config} from "../../config.ts";
|
|
||||||
import type { APIResponse } from "../types.ts";
|
|
||||||
|
|
||||||
const API_URL = config.apiUrl;
|
|
||||||
|
|
||||||
class AuthError extends Error {
|
|
||||||
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let onUnauthorized: (() => void) | null = null;
|
|
||||||
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
|
|
||||||
let refreshInFlight: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
export function registerUnauthorizedHandler(handler: () => void) {
|
|
||||||
onUnauthorized = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
|
|
||||||
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
|
|
||||||
onSessionRefreshed = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchanges the refresh token cookie for new access and refresh token cookies.
|
|
||||||
* Concurrent callers share a single request, so a burst of 401s only rotates
|
|
||||||
* the refresh token once.
|
|
||||||
*
|
|
||||||
* @returns true if the session was refreshed, false if it has ended (no valid
|
|
||||||
* refresh token). Throws on network errors so a dropped connection isn't
|
|
||||||
* mistaken for being logged out.
|
|
||||||
*/
|
|
||||||
function refreshSession(): Promise<boolean> {
|
|
||||||
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
|
|
||||||
return refreshInFlight;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestRefresh(): Promise<boolean> {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) return false;
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performs a request against the API, sending session cookies. An expired
|
|
||||||
* access token is refreshed and the request retried once, so callers only see
|
|
||||||
* an AuthError when the session has really ended — at which point the
|
|
||||||
* registered unauthorized handler drops the UI back to a logged out state.
|
|
||||||
* Returns the raw response so callers that need something other than JSON
|
|
||||||
* (blobs, streams) can read it themselves.
|
|
||||||
*/
|
|
||||||
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
|
|
||||||
const send = () => fetch(url, { ...options, credentials: 'include' });
|
|
||||||
|
|
||||||
let response = await send();
|
|
||||||
|
|
||||||
if (response.status == 401 && await refreshSession()) {
|
|
||||||
response = await send();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status == 401) {
|
|
||||||
onUnauthorized?.();
|
|
||||||
throw new AuthError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logged in, but not allowed to access this resource: don't end the session.
|
|
||||||
if (response.status == 403) {
|
|
||||||
throw new AuthError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
let message = text;
|
|
||||||
try { message = JSON.parse(text).message ?? text; } catch { /* not JSON, use raw text */ }
|
|
||||||
throw new Error(message || `Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const response = await apiFetchRaw(url, options);
|
|
||||||
const text = await response.text();
|
|
||||||
return text ? JSON.parse(text) : (undefined as T);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { APIResponse, Clip } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
|
||||||
|
|
||||||
const getClips = async (): Promise<Clip[]> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/clips');
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch clips: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getClipById = async (id: string): Promise<Clip | null> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`);
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch clip: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// URL for the raw media stream. The <video> element loads this directly so the
|
|
||||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
|
||||||
// of downloading the whole file up front.
|
|
||||||
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
|
|
||||||
|
|
||||||
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteClip = async (id: number): Promise<void> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' });
|
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { APIResponse, JobResponse } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch, apiFetchRaw } from "./client.ts";
|
|
||||||
|
|
||||||
const getJob = async (uuid: string): Promise<JobResponse> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/jobs/${uuid}`);
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadJob = async (uuid: string): Promise<Blob> => {
|
|
||||||
const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`);
|
|
||||||
return response.blob();
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getJob, downloadJob };
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { APIResponse, Marker } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
|
||||||
|
|
||||||
const getMarkers = async (): Promise<Marker[]> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers');
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarker = async (message: string): Promise<Marker> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteMarker = async (id: number): Promise<void> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' });
|
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getMarkers, createMarker, deleteMarker };
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { APIResponse, VideoMetadata } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
|
||||||
|
|
||||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('title', options.title);
|
|
||||||
if (options.description) formData.append('description', options.description);
|
|
||||||
formData.append('startPoint', options.startPoint.toString());
|
|
||||||
formData.append('duration', options.duration.toString());
|
|
||||||
if (options.fps) formData.append('fps', options.fps.toString());
|
|
||||||
if (options.width) formData.append('width', options.width.toString());
|
|
||||||
if (options.height) formData.append('height', options.height.toString());
|
|
||||||
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
|
||||||
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/compress', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ startTime, endTime, title, description }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save/markers', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/clip', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ duration, title, description }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.uuid;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { compress, saveSection, saveSectionByMarkers, clipSection };
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
|
||||||
|
|
||||||
const getStreamStatus = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
|
||||||
return result.status === 'success' && result.data?.isStreaming === true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentStream = async (): Promise<StreamStatus> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/stream/history/${userId}`);
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getStreamStatus, getCurrentStream, getStreamHistory };
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { APIResponse, User } from "../types.ts";
|
|
||||||
import { API_URL } from "./client.ts";
|
|
||||||
|
|
||||||
// These deliberately use fetch directly rather than apiFetch: they run when
|
|
||||||
// there may be no session at all, and apiFetch's refresh-and-retry on 401
|
|
||||||
// would either loop or log the user out from under the auth provider.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
|
|
||||||
* cookies; only the access token lifetime is returned.
|
|
||||||
*
|
|
||||||
* @returns seconds until the access token expires
|
|
||||||
*/
|
|
||||||
const login = async (googleToken: string): Promise<number> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: googleToken }),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data.accessTokenExpiresIn;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Revokes the session and clears the auth cookies. */
|
|
||||||
const logout = async (): Promise<void> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUser = async (): Promise<User | null> => {
|
|
||||||
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
const result: APIResponse = await response.json();
|
|
||||||
if (result.status === 'error') return null;
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { login, logout, getUser };
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { APIResponse, Vod } from "../types.ts";
|
|
||||||
import { API_URL, apiFetch } from "./client.ts";
|
|
||||||
|
|
||||||
const getVods = async (): Promise<Vod[]> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/vods');
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch vods: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getVodById = async (id: string): Promise<Vod | null> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`);
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to fetch vod: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// URL for the raw media stream. The <video> element loads this directly so the
|
|
||||||
// browser can issue HTTP Range requests (progressive loading + seeking) instead
|
|
||||||
// of downloading the whole file up front.
|
|
||||||
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
|
|
||||||
|
|
||||||
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteVod = async (id: number): Promise<void> => {
|
|
||||||
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
|
|
||||||
if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };
|
|
||||||
172
frontend/src/utils/endpoints.ts
Normal file
172
frontend/src/utils/endpoints.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor() { super("Not authenticated"); this.name = "AuthError"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (googleToken: string): Promise<string> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: googleToken }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('title', options.title);
|
||||||
|
if (options.description) formData.append('description', options.description);
|
||||||
|
formData.append('startPoint', options.startPoint.toString());
|
||||||
|
formData.append('duration', options.duration.toString());
|
||||||
|
if (options.fps) formData.append('fps', options.fps.toString());
|
||||||
|
if (options.width) formData.append('width', options.width.toString());
|
||||||
|
if (options.height) formData.append('height', options.height.toString());
|
||||||
|
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
|
||||||
|
|
||||||
|
const response = await fetch(API_URL + '/api/v1/media/compress', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data.uuid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getJob = async (uuid: string): Promise<JobResponse> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUser = async (): Promise<User | null> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') return null;
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClips = async (): Promise<Clip[]> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorResult: APIResponse = await response.json();
|
||||||
|
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClipById = async (id: string): Promise<Clip | null> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch clip: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) throw new AuthError();
|
||||||
|
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClip = async (id: number): Promise<void> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
|
||||||
|
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStreamStatus = async (): Promise<boolean> => {
|
||||||
|
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const result: APIResponse = await response.json();
|
||||||
|
return result.status === 'success' && result.data?.isStreaming === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||||
|
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
||||||
|
return response.ok;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
compress,
|
||||||
|
getJob,
|
||||||
|
getUser,
|
||||||
|
getClips,
|
||||||
|
getClipById,
|
||||||
|
getVideoBlob,
|
||||||
|
patchClip,
|
||||||
|
deleteClip,
|
||||||
|
getStreamStatus,
|
||||||
|
isThumbnailAvailable,
|
||||||
|
};
|
||||||
@@ -16,7 +16,6 @@ type APIResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: number,
|
|
||||||
name: string,
|
name: string,
|
||||||
username: string,
|
username: string,
|
||||||
email: string,
|
email: string,
|
||||||
@@ -34,15 +33,6 @@ type Clip = {
|
|||||||
createdAt: string,
|
createdAt: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Vod = {
|
|
||||||
id: number,
|
|
||||||
userId: number,
|
|
||||||
title: string,
|
|
||||||
description: string,
|
|
||||||
duration: number,
|
|
||||||
createdAt: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
type JobResponse = {
|
type JobResponse = {
|
||||||
uuid: string,
|
uuid: string,
|
||||||
progress: number,
|
progress: number,
|
||||||
@@ -52,34 +42,10 @@ type JobResponse = {
|
|||||||
createdAt: string,
|
createdAt: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamStatus = {
|
|
||||||
isStreaming: boolean,
|
|
||||||
id: number | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamHistoryItem = {
|
|
||||||
id: number,
|
|
||||||
startDate: string | null,
|
|
||||||
endDate: string | null,
|
|
||||||
lastSeen: string | null,
|
|
||||||
}
|
|
||||||
|
|
||||||
type Marker = {
|
|
||||||
id: number,
|
|
||||||
userId: number,
|
|
||||||
streamId: number,
|
|
||||||
message: string,
|
|
||||||
timestamp: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
APIResponse,
|
APIResponse,
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
User,
|
User,
|
||||||
Clip,
|
Clip,
|
||||||
Vod,
|
JobResponse
|
||||||
JobResponse,
|
|
||||||
StreamStatus,
|
|
||||||
StreamHistoryItem,
|
|
||||||
Marker
|
|
||||||
}
|
}
|
||||||
@@ -21,12 +21,6 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
|
|||||||
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clock time only, no date — markers within a stream are all from the same day,
|
|
||||||
// so the date would just be noise beside them.
|
|
||||||
function formatLocalTime(dateString: string): string {
|
|
||||||
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToTimeAgo(date: Date): string {
|
function dateToTimeAgo(date: Date): string {
|
||||||
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
const units: [number, string][] = [
|
const units: [number, string][] = [
|
||||||
@@ -46,52 +40,4 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startOfDay(date: Date): Date {
|
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weeks start on Monday.
|
|
||||||
function startOfWeek(date: Date): Date {
|
|
||||||
const day = startOfDay(date);
|
|
||||||
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
|
|
||||||
return day;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Buckets a date relative to now. Anything before last month falls back to its
|
|
||||||
// month and year, so older items still group sensibly.
|
|
||||||
function timeRangeLabel(date: Date, now: Date = new Date()): string {
|
|
||||||
const today = startOfDay(now);
|
|
||||||
const yesterday = new Date(today);
|
|
||||||
yesterday.setDate(today.getDate() - 1);
|
|
||||||
const thisWeek = startOfWeek(now);
|
|
||||||
const lastWeek = new Date(thisWeek);
|
|
||||||
lastWeek.setDate(thisWeek.getDate() - 7);
|
|
||||||
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
||||||
|
|
||||||
if (date >= today) return "Today";
|
|
||||||
if (date >= yesterday) return "Yesterday";
|
|
||||||
if (date >= thisWeek) return "This Week";
|
|
||||||
if (date >= lastWeek) return "Last Week";
|
|
||||||
if (date >= thisMonth) return "This Month";
|
|
||||||
if (date >= lastMonth) return "Last Month";
|
|
||||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeGroup<T> = { label: string, items: T[] };
|
|
||||||
|
|
||||||
// Expects items already sorted newest first; groups keep that order.
|
|
||||||
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
|
|
||||||
const now = new Date();
|
|
||||||
const groups: TimeGroup<T>[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
|
|
||||||
const last = groups[groups.length - 1];
|
|
||||||
if (last?.label === label) last.items.push(item);
|
|
||||||
else groups.push({ label, items: [item] });
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
|
|
||||||
export type { TimeGroup };
|
|
||||||
|
|||||||
8
frontend/src/vite-env.d.ts
vendored
8
frontend/src/vite-env.d.ts
vendored
@@ -1,9 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
|
||||||
_env_?: {
|
|
||||||
VITE_API_URL?: string;
|
|
||||||
VITE_FRONTEND_URL?: string;
|
|
||||||
VITE_GOOGLE_CLIENT_ID?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,27 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import flowbiteReact from "flowbite-react/plugin/vite";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => { // change to function form
|
|
||||||
const env = loadEnv(mode, process.cwd(), '') // load all env vars
|
|
||||||
|
|
||||||
return {
|
// https://vite.dev/config/
|
||||||
plugins: [react(), tailwindcss()],
|
export default defineConfig({
|
||||||
preview: {
|
plugins: [react(), tailwindcss(), flowbiteReact()],
|
||||||
port: 5173,
|
preview: {
|
||||||
strictPort: true,
|
port: 5173,
|
||||||
},
|
strictPort: true,
|
||||||
server: {
|
},
|
||||||
port: 5173,
|
server: {
|
||||||
strictPort: true,
|
port: 5173,
|
||||||
host: true,
|
strictPort: true,
|
||||||
origin: env.VITE_FRONTEND_URL || "http://0.0.0.0:5173", // use it here
|
host: true,
|
||||||
allowedHosts: true,
|
origin: "http://0.0.0.0:5173",
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
50
mvnw
vendored
50
mvnw
vendored
@@ -19,7 +19,7 @@
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
# Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
#
|
#
|
||||||
# Optional ENV vars
|
# Optional ENV vars
|
||||||
# -----------------
|
# -----------------
|
||||||
@@ -105,17 +105,14 @@ trim() {
|
|||||||
printf "%s" "${1}" | tr -d '[:space:]'
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
}
|
}
|
||||||
|
|
||||||
scriptDir="$(dirname "$0")"
|
|
||||||
scriptName="$(basename "$0")"
|
|
||||||
|
|
||||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
while IFS="=" read -r key value; do
|
while IFS="=" read -r key value; do
|
||||||
case "${key-}" in
|
case "${key-}" in
|
||||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
esac
|
esac
|
||||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
case "${distributionUrl##*/}" in
|
case "${distributionUrl##*/}" in
|
||||||
maven-mvnd-*bin.*)
|
maven-mvnd-*bin.*)
|
||||||
@@ -133,7 +130,7 @@ maven-mvnd-*bin.*)
|
|||||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
;;
|
;;
|
||||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
@@ -230,7 +227,7 @@ if [ -n "${distributionSha256Sum-}" ]; then
|
|||||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
exit 1
|
exit 1
|
||||||
elif command -v sha256sum >/dev/null; then
|
elif command -v sha256sum >/dev/null; then
|
||||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
|
||||||
distributionSha256Result=true
|
distributionSha256Result=true
|
||||||
fi
|
fi
|
||||||
elif command -v shasum >/dev/null; then
|
elif command -v shasum >/dev/null; then
|
||||||
@@ -255,41 +252,8 @@ if command -v unzip >/dev/null; then
|
|||||||
else
|
else
|
||||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
fi
|
fi
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
|
||||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
actualDistributionDir=""
|
|
||||||
|
|
||||||
# First try the expected directory name (for regular distributions)
|
|
||||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
|
||||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
|
||||||
actualDistributionDir="$distributionUrlNameMain"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
|
||||||
if [ -z "$actualDistributionDir" ]; then
|
|
||||||
# enable globbing to iterate over items
|
|
||||||
set +f
|
|
||||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
|
||||||
if [ -d "$dir" ]; then
|
|
||||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
|
||||||
actualDistributionDir="$(basename "$dir")"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
set -f
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$actualDistributionDir" ]; then
|
|
||||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
|
||||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
|
||||||
die "Could not find Maven distribution directory in extracted archive"
|
|
||||||
fi
|
|
||||||
|
|
||||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
|
||||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
|
||||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
|
||||||
|
|
||||||
clean || :
|
clean || :
|
||||||
exec_maven "$@"
|
exec_maven "$@"
|
||||||
|
|||||||
56
mvnw.cmd
vendored
56
mvnw.cmd
vendored
@@ -19,7 +19,7 @@
|
|||||||
@REM ----------------------------------------------------------------------------
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
@REM ----------------------------------------------------------------------------
|
@REM ----------------------------------------------------------------------------
|
||||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
@REM Apache Maven Wrapper startup batch script, version 3.3.2
|
||||||
@REM
|
@REM
|
||||||
@REM Optional ENV vars
|
@REM Optional ENV vars
|
||||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
@SET __MVNW_ARG0_NAME__=
|
@SET __MVNW_ARG0_NAME__=
|
||||||
@SET MVNW_USERNAME=
|
@SET MVNW_USERNAME=
|
||||||
@SET MVNW_PASSWORD=
|
@SET MVNW_PASSWORD=
|
||||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
|
||||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
@GOTO :EOF
|
@GOTO :EOF
|
||||||
: end batch / begin powershell #>
|
: end batch / begin powershell #>
|
||||||
@@ -73,30 +73,16 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
|||||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
if ($env:MVNW_REPOURL) {
|
if ($env:MVNW_REPOURL) {
|
||||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
|
||||||
}
|
}
|
||||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
|
||||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
|
||||||
if ($env:MAVEN_USER_HOME) {
|
if ($env:MAVEN_USER_HOME) {
|
||||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
|
||||||
}
|
}
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
|
||||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
$MAVEN_WRAPPER_DISTS = $null
|
|
||||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
|
||||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
|
||||||
} else {
|
|
||||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
|
||||||
}
|
|
||||||
|
|
||||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
|
||||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
|
||||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
@@ -148,33 +134,7 @@ if ($distributionSha256Sum) {
|
|||||||
|
|
||||||
# unzip and move
|
# unzip and move
|
||||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
|
||||||
$actualDistributionDir = ""
|
|
||||||
|
|
||||||
# First try the expected directory name (for regular distributions)
|
|
||||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
|
||||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
|
||||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
|
||||||
$actualDistributionDir = $distributionUrlNameMain
|
|
||||||
}
|
|
||||||
|
|
||||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
|
||||||
if (!$actualDistributionDir) {
|
|
||||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
|
||||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
|
||||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
|
||||||
$actualDistributionDir = $_.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$actualDistributionDir) {
|
|
||||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
|
||||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
|
||||||
try {
|
try {
|
||||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
FROM tiangolo/nginx-rtmp:latest-2026-09-07
|
FROM tiangolo/nginx-rtmp
|
||||||
|
|
||||||
RUN apt-get update -o Acquire::Check-Valid-Until=false && \
|
RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/*
|
||||||
apt-get install -y --no-install-recommends gettext-base && \
|
|
||||||
apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ rtmp {
|
|||||||
hls_path /stream;
|
hls_path /stream;
|
||||||
hls_fragment_naming system;
|
hls_fragment_naming system;
|
||||||
hls_fragment 3;
|
hls_fragment 3;
|
||||||
hls_playlist_length ${STREAM_RING_LENGTH_SECOND};
|
hls_playlist_length 28800;
|
||||||
|
|
||||||
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
|
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
|
||||||
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
# must only run on certain env files, otherwise it replaces too much
|
# must only run on certain env files, otherwise it replaces too much
|
||||||
envsubst '${WEB_SERVER_URL} ${STREAM_RING_LENGTH_SECOND}' \
|
envsubst '${WEB_SERVER_URL}' \
|
||||||
< /etc/nginx/templates/default.conf.template \
|
< /etc/nginx/templates/default.conf.template \
|
||||||
> /etc/nginx/nginx.conf
|
> /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
|||||||
15
pom.xml
15
pom.xml
@@ -85,21 +85,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Database Migration Tools -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-core</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.flywaydb</groupId>
|
|
||||||
<artifactId>flyway-database-postgresql</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
FROM maven:3.9.6-eclipse-temurin-21 AS builder
|
FROM maven:3.9.6-eclipse-temurin-21 AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests -B
|
RUN mvn clean package -DskipTests
|
||||||
|
|
||||||
|
|
||||||
# Run stage
|
# Run stage
|
||||||
FROM eclipse-temurin:21-jre
|
FROM eclipse-temurin:21-jdk
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends ffmpeg && \
|
apt-get install -y ffmpeg && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
package com.ddf.vodsystem;
|
package com.ddf.vodsystem;
|
||||||
|
|
||||||
import com.ddf.vodsystem.dto.properties.AuthProperties;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableConfigurationProperties(AuthProperties.class)
|
|
||||||
public class VodSystemApplication {
|
public class VodSystemApplication {
|
||||||
|
|
||||||
/**
|
|
||||||
* Application entry point; boots the Spring context.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments passed to Spring Boot
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(VodSystemApplication.class, args);
|
SpringApplication.run(VodSystemApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import java.util.concurrent.Executor;
|
|||||||
public class AsyncConfig implements AsyncConfigurer {
|
public class AsyncConfig implements AsyncConfigurer {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides the bounded thread pool that runs ffmpeg-backed {@code @Async} media tasks.
|
|
||||||
*
|
|
||||||
* @return the configured {@link Executor} registered as {@code ffmpegTaskExecutor}
|
|
||||||
*/
|
|
||||||
@Bean(name = "ffmpegTaskExecutor")
|
@Bean(name = "ffmpegTaskExecutor")
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
@@ -30,11 +25,6 @@ public class AsyncConfig implements AsyncConfigurer {
|
|||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
|
|
||||||
*
|
|
||||||
* @return the async uncaught-exception handler
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
|
||||||
return (ex, method, params) ->
|
return (ex, method, params) ->
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ public class CorsConfig {
|
|||||||
@Value("${frontend.url}")
|
@Value("${frontend.url}")
|
||||||
private String frontendUrl;
|
private String frontendUrl;
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures CORS for the API, allowing credentialed requests from the configured frontend origin.
|
|
||||||
*
|
|
||||||
* @return a {@link WebMvcConfigurer} that registers the CORS mappings for {@code /api/v1/**}
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcConfigurer corsConfigurer() {
|
public WebMvcConfigurer corsConfigurer() {
|
||||||
return new WebMvcConfigurer() {
|
return new WebMvcConfigurer() {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.ddf.vodsystem.configuration;
|
package com.ddf.vodsystem.configuration;
|
||||||
|
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
@@ -24,14 +22,6 @@ public class SecurityConfig {
|
|||||||
this.jwtFilter = jwtFilter;
|
this.jwtFilter = jwtFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
|
|
||||||
* installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules.
|
|
||||||
*
|
|
||||||
* @param http the HTTP security builder to configure
|
|
||||||
* @return the built {@link SecurityFilterChain}
|
|
||||||
* @throws Exception if the security configuration cannot be built
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -40,17 +30,13 @@ public class SecurityConfig {
|
|||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
|
||||||
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
|
|
||||||
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
|
|
||||||
.requestMatchers("/api/v1/media/**").authenticated()
|
.requestMatchers("/api/v1/media/**").authenticated()
|
||||||
.requestMatchers("/api/v1/users/me").authenticated()
|
.requestMatchers("/api/v1/jobs/**").authenticated()
|
||||||
// authenticated by the refresh token cookie, not the (possibly expired) access token
|
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
|
||||||
.requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll()
|
.requestMatchers("/api/v1/users/login").permitAll()
|
||||||
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
// 401 rather than the default 403, so clients know to refresh their session
|
|
||||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.ddf.vodsystem.configuration;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
import java.time.Clock;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class TimeConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public Clock clock() {
|
|
||||||
return Clock.systemUTC();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user