76 Commits

Author SHA1 Message Date
ec9a982fe9 docs(docstrings): add docstrings to public method interfaces (#25)
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
- Resolves #18

Reviewed-on: #25
2026-08-24 16:02:04 +01:00
38942776df refactor(frontend): create centralised auth manager (#24)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
- Resolves #17

Reviewed-on: #24
2026-08-22 21:06:38 +01:00
bf5fe6a35d build(ci): update site url
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-21 14:54:21 +01:00
9033261165 feat(clipping): add title requirement check
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 48s
CI / Deploy to server (push) Successful in 8s
2026-08-19 18:34:11 +01:00
9e82dc8574 fix(upload): compression job does not begin while unauthenticated (#22)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 2m30s
CI / Deploy to server (push) Successful in 40s
Resolves #20

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/22
2026-08-19 18:22:49 +01:00
1c098028ab perf(frontend): implement thumbnail caching (#19)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 2m24s
CI / Deploy to server (push) Successful in 13s
- Simply uses the Spring Side cache headers, which permits the browser to cache the thumbnail
- Max cache time of 2 days before re-fetch required
- Closes #13

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/19
2026-08-18 21:47:47 +01:00
9c6002fe77 fix(no access page): missing mono colour scheme
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-17 23:13:52 +01:00
2f33101ec3 build(frontend): update docker image to use production env
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 27s
CI / Deploy to server (push) Successful in 8s
2026-08-17 23:06:25 +01:00
da38e21935 fix(upload): make entire dropzone clickable to open file picker
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 42s
CI / Deploy to server (push) Successful in 8s
2026-08-16 17:22:53 +01:00
3db16cd380 feat(style): update fonts & themes in frontend (#14)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 4m17s
CI / Deploy to server (push) Successful in 1m9s
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/14
2026-08-16 16:17:41 +01:00
29bc60d417 fix(streaming): missing HLS segments in edge requests (#10)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 3m0s
CI / Deploy to server (push) Successful in 16s
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/10
2026-07-29 18:49:59 +01:00
92cecb1084 chore: remove sql logs from prod
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 2m7s
CI / Deploy to server (push) Successful in 12s
2026-07-25 14:31:51 +01:00
ae8f2f33a7 perf(api): add HTTP range requests on media sources (#9)
All checks were successful
CI / Maven unit tests (push) Successful in 20s
CI / Build and push images (push) Successful in 5m14s
CI / Deploy to server (push) Successful in 1m29s
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/9
2026-07-25 11:06:39 +01:00
74a9d5752d feat(frontend): improve browser icon
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 45s
CI / Deploy to server (push) Successful in 20s
2026-07-13 23:45:56 +01:00
fe25ef05dc build(ci): improve caching efficiency in ci
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 25s
CI / Deploy to server (push) Successful in 5s
2026-07-13 23:39:43 +01:00
339465b417 feat(frontend): change browser icon
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 3m19s
CI / Deploy to server (push) Successful in 43s
2026-07-13 23:20:45 +01:00
3a46048b66 build(ci): add auto-deployment
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 4m1s
CI / Deploy to server (push) Successful in 44s
2026-07-13 20:35:33 +01:00
740f904d92 Merge pull request 'FFMPEG Classes Unit Testing' (#8) from test/ffmpeg-unit-tests into master
All checks were successful
Unit Tests / Maven unit tests (push) Successful in 14s
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/8
2026-07-12 20:00:16 +01:00
e902d54a4d fix(ci): incorrect url
All checks were successful
Unit Tests / Maven unit tests (push) Successful in 2m11s
2026-07-12 19:11:09 +01:00
d621aa3d6a build(ci): unit test
Some checks failed
Unit Tests / Maven unit tests (push) Failing after 45s
2026-07-12 19:08:41 +01:00
f9b055b3b5 test(unit): improve test coverage 2026-07-12 19:06:10 +01:00
bb6f5e7823 test(unit): add Thumbnail and Remux service tests 2026-07-12 18:59:44 +01:00
2408263988 fix(media): propagate async FFmpeg results and failures 2026-07-12 18:49:20 +01:00
688ff541da test(unit): StreamActionsService tests 2026-07-12 18:11:45 +01:00
07ee112d57 refactor(stream actions): move segment logic into StreamActionsService 2026-07-08 22:17:10 +02:00
fbb5c3ab52 fix(interrupts): metadata service not setting interrupt bit 2026-07-08 11:57:01 +02:00
e1e6fe0046 fix(file size): use Long instead of float
Float only stores up to 16 million, and file size is stored in bytes. A video can easily be over 16MB (16 million bytes), therefore Long is needed.
2026-07-07 20:48:37 +02:00
a66b9898e8 test(unit): metadata unit tests 2026-07-07 20:26:23 +02:00
baaf162fee test(unit): maximum bitrate assertions 2026-07-04 14:06:32 +01:00
d7214ca1bc test(unit): compression service test 2026-07-02 20:59:13 +01:00
5bebea3f95 refactor(command): inject CommandRunner instead of static 2026-07-02 20:04:04 +01:00
947c8261fd Merge pull request 'Frontend page for controlling streaming actions' (#7) from feat/frontend-streaming-controls into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/7
2026-07-02 19:46:32 +01:00
e0969a1d78 refactor(frontend): split endpoint handling 2026-07-02 19:46:19 +01:00
fe735faba8 feat(api): delete marker endpoint 2026-07-02 19:37:35 +01:00
b8b4d3b05c feat(frontend): streaming controls page 2026-07-02 19:27:23 +01:00
a051407c1e Merge pull request 'Stream Markers' (#6) from feat/stream-markers into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/6
2026-07-02 19:14:43 +01:00
41fda53817 docs(docstring): other stream save method 2026-07-02 19:13:55 +01:00
1a0778d1d6 docs(bruno): save with marker endpoint 2026-07-02 19:09:25 +01:00
ba378680c6 feat(markers): add save endpoint with markers 2026-07-02 19:09:07 +01:00
2cac1770f9 feat(markers): auto-delete after 8 hours 2026-07-02 18:53:23 +01:00
dc33d1df33 feat(markers): get by id 2026-07-02 18:35:22 +01:00
62dc0b0491 docs(bruno): marker endpoints 2026-07-02 17:50:29 +01:00
18626a5db8 feat(markers): implement marker entity & service 2026-07-02 17:43:13 +01:00
3c8df9b328 fix(clips): remove redundant auth checks 2026-07-01 23:19:51 +01:00
76869c8c90 fix(clips): remove redundant Optional returns 2026-07-01 23:12:23 +01:00
e5fcf9b966 Merge pull request 'Implement VoDs in frontend and backend' (#5) from feat/full-vod-support into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/5
2026-07-01 22:32:39 +01:00
4ec5080edc docs(docstrings): add clip & vod comments 2026-07-01 22:32:08 +01:00
14e8bd137b docs(bruno): add title/desc attr 2026-07-01 22:26:47 +01:00
998eebb032 feat(stream): implement description & title options 2026-07-01 22:23:49 +01:00
e2c94d0740 feat(stream): stream save now auto-persists vod 2026-07-01 22:12:32 +01:00
0b929e96da docs(bruno): implement bruno vods endpoints 2026-07-01 22:04:54 +01:00
1fff0805f6 feat(frontend): implement vods in frontend 2026-07-01 22:01:52 +01:00
48ff0eb8e5 feat(frontend): implement vods in frontend 2026-07-01 22:01:41 +01:00
83fcb44670 feat(backend): implement VoD functionality 2026-07-01 21:54:17 +01:00
913c5c8790 fix(exceptions): catch specific error instead of broad 2026-07-01 21:36:50 +01:00
a330471bec feat(frontend): add upload icon 2026-06-29 20:07:11 +01:00
d8b61b4528 docs: update readme 2026-06-26 15:49:12 +01:00
85ddd93757 fix(api): cookies not set on http connections
Frontend uses .env URL.
2026-06-26 14:47:15 +01:00
0b2c875889 fix(datetime): local datetime parsing 2026-06-25 23:17:03 +01:00
7db25194bc fix(datetime): incorrect datetime parsing 2026-06-25 23:10:32 +01:00
3d57007074 fix(maintainability): clean up reliability issues 2026-06-25 20:39:06 +01:00
5e00965baa Merge pull request 'Refactor job into being a simple UUID tracker' (#3) from refactor/generalize-job-abstraction into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/3
2026-06-25 20:26:08 +01:00
0e13e44feb fix(tailwind): top-level gitignore cause tailwind css issues
20:22
Scoped the bare video entry in the root .gitignore to its intended path. Tailwind V4 respects all .gitignore files in the project when scanning for utility classes, so the bare entry was silently preventing components/video/* from being scanned and its classes from appearing in the computed CSS output. The bug was absent in Docker as the root .gitignore was never copied into the container.
2026-06-25 20:22:44 +01:00
42294703ae chore(backend): remove LocalDateTime usage, replaced with Instant 2026-06-25 20:21:48 +01:00
755a18fc3a chore: update gitignore 2026-06-23 21:39:53 +01:00
8b299dc4fa fix(directories): inputs absolute paths into db 2026-06-23 21:31:45 +01:00
5651aa4ce9 refactor(directories): switch from File to Path objs 2026-06-23 20:53:24 +01:00
8b49a10a8c refactor(frontend): remove dead code 2026-06-22 22:50:07 +01:00
e36913d671 fix(frontend): update frontend to match new endpoints 2026-06-22 22:42:13 +01:00
7d8a62d331 docs(bruno): missing post response script 2026-06-22 22:34:18 +01:00
c571e12099 docs(bruno): remove old endpoints 2026-06-22 22:10:33 +01:00
e9abdfb44a docs(bruno): new bruno files 2026-06-22 22:09:10 +01:00
dc5af8f868 docs(bruno): overhaul bruno to match new API 2026-06-22 22:08:56 +01:00
da6bf16d07 refactor(media): consolidate upload/edit/download into MediaService
Merge UploadService, EditService, JobOrchestrationService, and
DownloadService into a single MediaService with a unified Job lifecycle
(READY → PROCESSING → SUCCEEDED/FAILED via JobState enum).

- Add MediaController (/api/v1/media) exposing compress, save, and clip
- Add JobController (/api/v1/job) for status polling and file download
- Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail)
- Move stream saveSection/clip from StreamService into MediaService
- Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file
- Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration
- Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner
- Rename GET /api/v1/auth/user → GET /api/v1/user/me
- Delete DownloadController, MetadataController, UploadController
2026-06-22 20:54:16 +01:00
b732c76aef Merge branch 'master' into refactor/generalize-job-abstraction 2026-06-22 18:32:45 +01:00
6c1f1112dc feat(stream): clipping stream now saves to db 2026-06-21 18:31:26 +01:00
195 changed files with 8005 additions and 4231 deletions

View File

@@ -16,3 +16,5 @@ POSTGRES_DB=vodSystem
FRONTEND_URL= FRONTEND_URL=
BACKEND_URL= BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800

118
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,118 @@
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 }}

6
.gitignore vendored
View File

@@ -39,8 +39,8 @@ build/
.vscode/ .vscode/
### Generated Files ### ### Generated Files ###
videos/ /videos
outputs/ /outputs
frontend/.flowbite-react
generated generated
.mvn

3
.mvn/wrapper/maven-wrapper.properties vendored Normal file
View File

@@ -0,0 +1,3 @@
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
View File

@@ -1,33 +1,121 @@
# Overview # VoD System
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:
- Upload clips effortlessly and securely. 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.
- Edit and trim videos with intuitive controls.
- Compress files to specific file sizes.
- Organize your clips for quick access and sharing.
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. 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.
---
## 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)
# Future Plans - Docker and Docker Compose
- **Format Handling** - A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
- Backend conversion of non-MP4 files via FFMPEG for broader format support
- **Input Sources** That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
- Support for local file uploads and YouTube imports
- **Testing** ---
- Unit tests, including coverage for FFMPEG-related functionality
- **API Documentation** ## Getting Started
- 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

View File

@@ -0,0 +1,16 @@
meta {
name: Delete Clip
type: http
seq: 5
}
delete {
url: {{base_url}}/clips/6
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip Media
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5/media
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Clip Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/clips/5/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 4
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get all clips name: Get all clips
type: http type: http
seq: 1 seq: 2
} }
get { get {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Update Clip name: Update Clip
type: http type: http
seq: 2 seq: 3
} }
patch { patch {

View File

@@ -1,5 +1,6 @@
meta { meta {
name: Clips name: Clips
seq: 1
} }
auth { auth {

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Download
type: http
seq: 2
}
get {
url: {{base_url}}/download/output/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Download
type: http
seq: 1
}
get {
url: {{base_url}}/download/input/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Download
}

View File

@@ -1,17 +0,0 @@
meta {
name: Edit
type: http
seq: 1
}
post {
url: {{base_url}}/edit/{{uuid}}
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
startPoint: 10
duration: 40
title: best possible title
}

View File

@@ -1,11 +0,0 @@
meta {
name: Progress
type: http
seq: 3
}
get {
url: {{base_url}}/progress/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Edit
}

View File

@@ -0,0 +1,15 @@
meta {
name: Download Video
type: http
seq: 2
}
get {
url: {{base_url}}/jobs/{{uuid}}/download
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Job
type: http
seq: 1
}
get {
url: {{base_url}}/jobs/{{uuid}}
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: Jobs
seq: 5
}
auth {
mode: inherit
}

View File

@@ -1,18 +1,18 @@
meta { meta {
name: Save Clip name: Create Marker
type: http type: http
seq: 3 seq: 3
} }
post { post {
url: {{base_url}}/stream/clip url: {{base_url}}/markers
body: json body: json
auth: inherit auth: inherit
} }
body:json { body:json {
{ {
"duration": 60 "message": "Nice play"
} }
} }

View File

@@ -0,0 +1,16 @@
meta {
name: Delete Marker
type: http
seq: 4
}
delete {
url: {{base_url}}/markers/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Marker
type: http
seq: 1
}
get {
url: {{base_url}}/markers/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,11 +1,11 @@
meta { meta {
name: Process name: Get all markers
type: http type: http
seq: 2 seq: 2
} }
get { get {
url: {{base_url}}/process/{{uuid}} url: {{base_url}}/markers
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -0,0 +1,8 @@
meta {
name: Markers
seq: 6
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,23 @@
meta {
name: Clip Stream
type: http
seq: 3
}
post {
url: {{base_url}}/media/clip
body: json
auth: inherit
}
body:json {
{
"duration": 60.0,
"title": "Hello",
"description": "my clip"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,22 @@
meta {
name: Compress
type: http
seq: 1
}
post {
url: {{base_url}}/media/compress
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4)
title: hello
startPoint: 10
duration: 30
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,24 @@
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);
}

View File

@@ -0,0 +1,24 @@
meta {
name: Save Stream Section
type: http
seq: 2
}
post {
url: {{base_url}}/media/save
body: json
auth: inherit
}
body:json {
{
"startTime": "2026-07-01T21:09:00Z",
"endTime": "2026-07-01T21:12:00Z",
"title": "Hello",
"description": "my clip"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,8 @@
meta {
name: Media
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Metadata
type: http
seq: 2
}
get {
url: {{base_url}}/metadata/converted/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Metadata
type: http
seq: 1
}
get {
url: {{base_url}}/metadata/original/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Metadata
}

View File

@@ -1,23 +0,0 @@
meta {
name: Save Section
type: http
seq: 2
}
post {
url: {{base_url}}/stream/save
body: json
auth: inherit
}
body:json {
{
"startTime": "2026-06-20T15:00:00Z",
"endTime": "2026-06-20T15:01:00Z"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,12 +1,8 @@
meta { meta {
name: Streams name: Streams
seq: 7 seq: 4
} }
auth { auth {
mode: bearer mode: inherit
}
auth:bearer {
token: {{token}}
} }

View File

@@ -1,19 +0,0 @@
meta {
name: Upload
type: http
seq: 1
}
post {
url: {{base_url}}/upload
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4)
}
script:post-response {
bru.setEnvVar("uuid", res.body.data.uuid);
}

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: {{base_url}}/auth/user url: {{base_url}}/users/me
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{base_url}}/auth/login url: {{base_url}}/users/login
body: json body: json
auth: none auth: none
} }

View File

@@ -0,0 +1,8 @@
meta {
name: Users
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Delete VoD
type: http
seq: 4
}
delete {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
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
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get VoD Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 5
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get VoD
type: http
seq: 2
}
get {
url: {{base_url}}/vods/1
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,11 @@
meta {
name: Get all vods
type: http
seq: 1
}
get {
url: {{base_url}}/vods
body: none
auth: inherit
}

View File

@@ -0,0 +1,23 @@
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
}

View File

@@ -0,0 +1,8 @@
meta {
name: VoDs
seq: 2
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,8 @@
vars {
base_url: http://http://server.tail3cd897.ts.net:8087/api/v1
uuid:
}
vars:secret [
google_client_id,
google_secret
]

View File

@@ -20,10 +20,11 @@ services:
context: ./nginx context: ./nginx
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: vod_rtmp container_name: vod_rtmp
extra_hosts: network_mode: host
- "host.docker.internal:host-gateway" env_file:
- .env
environment: environment:
- WEB_SERVER_URL=http://host.docker.internal:8080 - WEB_SERVER_URL=http://localhost:8080
ports: ports:
- "1935:1935" - "1935:1935"
volumes: volumes:

57
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,57 @@
# 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

View File

@@ -8,8 +8,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data - ${POSTGRES_DATA}:/var/lib/postgresql/data
ports:
- "5432:5432"
backend: backend:
build: build:
@@ -21,8 +19,8 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ${MEDIA_DATA}:/app/data/media - ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/stream - ${STREAM_DATA}:/app/stream
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped
@@ -34,11 +32,12 @@ 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:5173" - "5173:80"
restart: unless-stopped restart: unless-stopped
rtmp: rtmp:
@@ -46,6 +45,8 @@ 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:

View File

@@ -1,9 +1,19 @@
FROM node:20-alpine # --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm npm ci
COPY . . COPY . .
EXPOSE 5173 RUN npm run build
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;"]

10
frontend/env.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/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

View File

@@ -0,0 +1,15 @@
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;
}
}

View File

@@ -2,9 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/small_icon.png" />
<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" />
<title>Vite + React + TS</title> <script src="/env-config.js"></script>
<title>The VoD System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -8,27 +8,21 @@
"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",
@@ -36,9 +30,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.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#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;
}

View File

@@ -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,10 +6,18 @@ 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 VideoPlayer from "./pages/VideoPlayer.tsx"; import MyVods from './pages/MyVods';
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";
function App() { function App() {
@@ -18,7 +26,8 @@ function App() {
}, []); }, []);
return ( return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}> <GoogleOAuthProvider clientId={config.googleClientId}>
<AuthProvider>
<Router> <Router>
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
@@ -26,12 +35,18 @@ function App() {
<Route path="/create" element={<ClipUpload />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} /> <Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route> </Route>
</Routes> </Routes>
</Router> </Router>
</AuthProvider>
</ GoogleOAuthProvider> </ GoogleOAuthProvider>
); );
} }

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,20 @@
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. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, 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";
/**
* 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.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
await refresh();
}, [refresh]);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

@@ -0,0 +1,14 @@
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 };

View File

@@ -1,16 +1,15 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
type props = { type Props = {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
} };
const Box = ({children, className}: props) => { const Box = ({ children, className }: Props) => (
return ( <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>
) );
}
export default Box; export default Box;

View File

@@ -1,60 +1,51 @@
import React, {useEffect, useRef} from "react"; import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
type DropdownItemProps = { type DropdownItemProps = {
item: string; item: string;
onClick: (item: string) => void; onClick: (item: string) => void;
className?: string; className?: string;
} };
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => { const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
return ( <li
<li className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)} className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
onClick={() => onClick(item)} onClick={() => onClick(item)}
> >
{item} {item}
</li> </li>
); );
}
type DropdownProps = { type DropdownProps = {
label: React.ReactNode; label: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} };
const Dropdown = ({ label, children, className }: DropdownProps) => { const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => { useEffect(() => {
function handleClickOutside(event: { target: any }) { const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target)) { if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false); setIsOpen(false);
} }
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
}; };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
return ( return (
<div className={clsx(className, "relative inline-block text-left")} <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
ref={ref}>
<button <button
onClick={toggleDropdown} onClick={() => setIsOpen(v => !v)}
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" className="inline-flex items-center gap-1 px-3 py-2 text-sm 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-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden"> <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">
{children} {children}
</ul> </ul>
)} )}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
); );
}; };
export { Dropdown, DropdownItem }; export { Dropdown, DropdownItem };

View File

@@ -1,20 +1,16 @@
import React from "react"; import React from "react";
type props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
label: String; label: string;
} };
const Selector = ({children, label}: props) => { const Selector = ({ children, label }: Props) => (
return ( <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"}> <div className="w-px h-5 text-text-secondary mx-2" />
{ label }
</label>
<div className={"w-px h-5 bg-gray-200 mx-2"} />
{children} {children}
</div> </div>
) );
}
export default Selector; export default Selector;

View File

@@ -1,49 +1,51 @@
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 } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import type {User} from "../utils/types.ts"; import { useAuth } from "../auth/useAuth.ts";
type props = { type props = {
user: User | null;
className?: string className?: string
} }
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />; const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({className}: props) => {
const { user } = useAuth();
const Sidebar = ({user, className}: props) => {
return ( return (
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}> <aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<div className="px-4 pt-5 pb-3"> <h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
<img
className="w-36 mx-auto" <nav className="flex flex-col flex-1 items-center w-3/4">
src="../../public/logo.png" <Divider />
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>
{user && (
<>
<Divider /> <Divider />
<div className="flex flex-col gap-0.5">
{user && (
<div className="flex flex-col gap-0.5 w-full">
<div>
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
{/*<SidebarButton url="/vods" logo={<Video size={18}/>} label="VoDs" />*/} <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>
); );

View File

@@ -1,21 +1,18 @@
import { Menu, X } 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";
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;
} }
@@ -23,12 +20,11 @@ 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 handleLogin = (response: CredentialResponse) => { const handleLogin = (response: CredentialResponse) => {
if (!response.credential) { if (!response.credential) {
@@ -37,10 +33,6 @@ 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);
}); });
@@ -48,23 +40,20 @@ const Topbar = ({
const handleLogout = () => { const handleLogout = () => {
logout() logout()
.then(() => { .then(() => navigate("/"))
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-white border-b border-gray-200")}> <div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}> <MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>} {sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton> </MenuButton>
{ user ? ( { user ? (
<div> <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}
@@ -76,8 +65,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-red-500 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
</span> </span>
)} )}
</span> </span>
@@ -86,7 +75,7 @@ const Topbar = ({
onClick={() => navigate("/profile")} /> onClick={() => navigate("/profile")} />
<DropdownItem item="Logout" <DropdownItem item="Logout"
onClick={() => handleLogout()} onClick={() => handleLogout()}
className={"text-red-500 font-medium"} /> className={"text-error font-medium"} />
</Dropdown> </Dropdown>
</div> </div>
) : ) :

View File

@@ -1,14 +0,0 @@
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;

View File

@@ -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-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)} className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
{...props} {...props}
/> />
); );

View File

@@ -0,0 +1,14 @@
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;

View File

@@ -1,21 +1,27 @@
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;
logo: React.ReactNode; logo: React.ReactNode;
label: String; label: string;
} }
const SidebarButton = ({url, logo, label}: props) => { const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url); const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
return ( return (
<Link className="w-full" to={url}> <Link className="w-full" to={url}>
<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"}`}> <button className={`flex items-center gap-2.5 w-full text-sm
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}
</MenuButton> </button>
</Link> </Link>
) )
} }

View File

@@ -1,42 +1,32 @@
import RangeSlider from 'react-range-slider-input'; import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import {useRef} from "react"; import { useRef, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
export default function ClipRangeSlider({videoRef, export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
videoMetadata, const previousRange = useRef<[number, number]>([0, 0]);
setSliderValue,
setMetadata,
className}: Props) {
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
const handleRangeSliderInput = (val: [number, number]) => { const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return; if (!videoRef) return;
if (previousRangeSliderInput.current[0] != val[0]) { if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0]; videoRef.currentTime = val[0];
setSliderValue(val[0]); setSliderValue(val[0]);
} else if (previousRangeSliderInput.current[1] != val[1]) { } else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1]; videoRef.currentTime = val[1];
setSliderValue(val[1]); setSliderValue(val[1]);
} }
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
...prevState, previousRange.current = val;
startPoint: val[0],
duration: val[1] - val[0]
}
))
previousRangeSliderInput.current = val;
}; };
return ( return (
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
step={0.1} step={0.1}
onInput={handleRangeSliderInput} onInput={handleRangeInput}
className={clsx(className)} className={className}
id={"range-slider"} id="range-slider"
/> />
) );
} }

View File

@@ -1,41 +1,32 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
type prop = { type Props = {
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
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 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";
export default function ConfigBox({setMetadata, className}: prop) { export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const vals = e.target.value.split(","); const [width, height] = e.target.value.split(",").map(Number);
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, width, height }));
...prevState, };
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
...prevState, };
fps: parseInt(e.target.value)
}))
}
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
...prevState, };
fileSize: parseInt(e.target.value) * 1000
}))
}
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 font-semibold text-gray-800 mb-1">Export Settings</h2> <h2 className="text-sm text-text-primary font-semibold 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"
@@ -64,5 +55,5 @@ export default function ConfigBox({setMetadata, className}: prop) {
/> />
</Selector> </Selector>
</div> </div>
) );
} }

View File

@@ -1,30 +1,47 @@
import BlueButton from "../buttons/BlueButton.tsx"; import PrimaryButton from "../buttons/PrimaryButton.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 ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => { const Spinner = () => (
<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"}>
<BlueButton <PrimaryButton
onClick={dataSend}> onClick={dataSend}
Export disabled={uploading}
</BlueButton> className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</PrimaryButton>
<div className="h-10 flex items-center"> <div className="h-10 flex items-center">
{ downloadable ? { downloadable ?
(<BlueButton className="w-full" onClick={handleDownload}> (<PrimaryButton className="w-full" onClick={handleDownload}>
Download Download
</BlueButton>) </PrimaryButton>)
:( :(
<progress <progress
value={progress} value={progress}
className="w-full h-1.5 rounded bg-gray-200"> className="w-full h-1.5 rounded bg-fields">
</progress> </progress>
) )
} }

View File

@@ -1,26 +1,25 @@
import clsx from "clsx"; import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = { type Props = {
setMetadata: Function setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => { const MetadataBox = ({ setMetadata, className }: Props) => (
return (
<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-semibold text-gray-800 text-sm">Title</p> <p className="w-full font-body text-text-strong font-bold text-sm">
Title <span className="text-error">*</span>
</p>
<input <input
type="text" type="text"
required
placeholder="Enter title" placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({ onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
...prevState, 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"
title: e.target.value
}))}
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>
) );
}
export default MetadataBox; export default MetadataBox;

View File

@@ -1,52 +1,37 @@
import {useEffect} from "react"; import { useEffect, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
sliderValue: number; sliderValue: number;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
className?: string; className?: string;
}; };
export default function PlaybackSlider({videoRef, export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
videoMetadata,
sliderValue,
setSliderValue,
className}: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => { const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return; if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber; videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber); setSliderValue(e.target.valueAsNumber);
} };
// update slider
useEffect(() => { useEffect(() => {
if (!videoRef) return; if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
const updateSlider = () => {
setSliderValue(videoRef.currentTime);
};
videoRef.addEventListener("timeupdate", updateSlider); videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
return () => {
videoRef.removeEventListener("timeupdate", updateSlider);
};
}, [videoRef]); }, [videoRef]);
return ( return (
<input <input
type={"range"} type="range"
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
value={sliderValue} value={sliderValue}
onChange={updateVideo} onChange={updateVideo}
step={0.1} step={0.1}
className={clsx(className)} className={className}
/> />
) );
} }

View File

@@ -1,10 +1,9 @@
import { useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react'; import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts"; import { formatTime } from "../../utils/utils.ts";
type Props = { type Props = {
video: HTMLVideoElement | null; video: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => { const togglePlay = () => {
if (!video) return; if (!video) return;
if (video.paused) { if (video.paused) {
video.play(); video.play();
setIsPlaying(true); setIsPlaying(true);
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
} }
}; };
let Icon;
// update icon
if (volume == 0) {
Icon = VolumeX;
} else if (volume < 50) {
Icon = Volume1;
} else {
Icon = Volume2;
}
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => { const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!video) return; if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
let volume = parseInt(e.target.value) // Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
video.volume = volume / 100;
setVolume(volume);
}
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
useEffect(() => { useEffect(() => {
if (!video) return; if (!video) return;
const handlePlay = () => setIsPlaying(true); const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false); const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay); video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause); video.addEventListener("pause", handlePause);
return () => { return () => {
video.removeEventListener("play", handlePlay); video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause); video.removeEventListener("pause", handlePause);
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
return ( return (
<div className={clsx("flex justify-between items-center mb-2", className)}> <div className={clsx("flex justify-between items-center mb-2", className)}>
<div className={"flex gap-1"}> <div className="flex gap-1">
<Icon size={24} /> <Icon size={24} />
<input <input
type='range' type="range"
min={0} min={0}
max={100} max={100}
onChange={updateVolume}
value={volume} value={volume}
className={"w-20"} onChange={updateVolume}
className="w-20"
/> />
</div> </div>
<button <button
onClick={togglePlay} onClick={togglePlay}
className={"hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"} className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
> >
{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">
<label>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</label> </span>
</div> </div>
); );
} }

View File

@@ -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 { useEffect, useRef, useState } from "react"; import React, { 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,27 +13,28 @@ 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 = import.meta.env.VITE_API_URL; const API_URL = config.apiUrl;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => { const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [imgFailed, setImgFailed] = useState(false);
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`;
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) const interval = setInterval(() => {
.then(setThumbnailAvailable) setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
.catch(() => setThumbnailAvailable(false)); }, 1000);
}, []); return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -54,16 +55,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
}; };
return ( return (
<Link to={"/video/" + id}> <Link to={`${playerPath}/${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={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail} src={imgFailed ? fallbackThumbnail : thumbnailUrl}
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-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none"> <span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
@@ -75,18 +77,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-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors" className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark 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-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden"> <div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline 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-gray-700 hover:bg-gray-50" className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
> >
<Pencil size={13} /> Edit <Pencil size={13} /> Edit
</button> </button>
@@ -94,7 +96,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-red-500 hover:bg-red-50" className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
> >
<Trash2 size={13} /> Delete <Trash2 size={13} /> Delete
</button> </button>
@@ -102,17 +104,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-gray-600 mb-2">Delete this clip?</p> <p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</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-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors" className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover 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-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors" className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -126,10 +128,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-medium text-gray-900 text-sm leading-snug line-clamp-2"> <p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title} {title === "" ? "(No Title)" : title}
</p> </p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p> <p className="text-muted text-xs mt-0.5">{timeAgo}</p>
</div> </div>
</div> </div>
</Link> </Link>

5
frontend/src/config.ts Normal file
View File

@@ -0,0 +1,5 @@
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 ?? '',
};

View File

@@ -1,37 +1,51 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme { @theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; /* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Breakpoints */ /* ── Surfaces ────────────────────────────────────────────────────────── */
--breakpoint-3xl: 1920px; --color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
--color-primary: oklch(0.55 0.21 254); /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-primary-pressed: oklch(0.45 0.21 254); --color-text-primary: #2B2620;
--color-secondary: oklch(0.94 0.01 250); --color-text-strong: #35302A;
--color-accent: oklch(0.68 0.20 288); --color-text-body: #554D44;
--color-text: oklch(0.17 0.01 270); --color-text-secondary: #6E6559;
--color-background: oklch(0.97 0.005 250); --color-muted: #9A9084;
--color-surface: oklch(1 0 0); --color-faint-mono: #A79C8E;
--color-border: oklch(0.91 0.008 250);
/* Easing */ /* ── Accents ─────────────────────────────────────────────────────────── */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --color-terracotta: #A85A38;
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); --color-terracotta-hover: #94492A;
} --color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
#range-slider .range-slider__range { /* ── Status ──────────────────────────────────────────────────────────── */
background: var(--color-primary); --color-live: #B5432E;
} --color-error: #B5432E;
#range-slider .range-slider__thumb { /* ── Ink on dark / accent surfaces ───────────────────────────────────── */
background: var(--color-primary); --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;
} }
:root { :root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: "IBM Plex Sans", 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;
@@ -42,4 +56,35 @@
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);
}
/* 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);
} }

View File

@@ -3,26 +3,13 @@ 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 type {User} from "../utils/types"; import { useAuth } from "../auth/useAuth";
import { getUser, getStreamStatus } from "../utils/endpoints"; import { getStreamStatus } from "../utils/api/stream";
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;
@@ -33,16 +20,13 @@ const MainLayout = () => {
}, [user]); }, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_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 />

View File

@@ -5,12 +5,14 @@ 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 { editFile, processFile, getProgress, uploadFile } from "../utils/endpoints" import { compress } from "../utils/api/media"
import { getJob } 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";
import {config} from "../config.ts";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = config.apiUrl;
const ClipEdit = () => { const ClipEdit = () => {
const location = useLocation(); const location = useLocation();
@@ -34,6 +36,7 @@ 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,47 +62,56 @@ const ClipEdit = () => {
}; };
const sendData = async () => { const sendData = async () => {
if (!localFile) return; if (!localFile || uploading) return;
let fileId: string; const title = outputMetadata.title.trim();
try { if (!title) {
fileId = await uploadFile(localFile); setError('A title is required before exporting.');
setUploadedId(fileId);
} catch (err: unknown) {
setError(`Failed to upload file: ${err instanceof Error ? err.message : 'Unknown error'}`);
return; return;
} }
editFile(fileId, outputMetadata) setUploading(true);
.then(() => processFile(fileId) setError(null);
.catch((err: Error) => setError(`Failed to process file: ${err.message}`)))
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async () => await pollProgress(fileId, interval), 500); let jobId: string;
try {
jobId = await compress(localFile, { ...outputMetadata, title });
setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
setUploading(false);
return;
}
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
}; };
const pollProgress = async (id: string, intervalId: number) => { const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
getProgress(id) getJob(jobId)
.then((progress) => { .then((job) => {
setProgress(progress.process.progress); setProgress(job.progress);
if (progress.process.complete) { if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
setUploading(false);
} else if (job.isComplete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
} else { setUploading(false);
setDownloadable(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;
const response = await fetch(API_URL + `/api/v1/download/output/${uploadedId}`); const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
console.error('Download failed'); console.error('Download failed');
@@ -123,7 +135,7 @@ const ClipEdit = () => {
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"} className={"w-full rounded-lg shadow-sm bg-text-primary"}
onLoadedMetadata={handleVideoMetadataLoaded} onLoadedMetadata={handleVideoMetadataLoaded}
/> />
@@ -137,7 +149,7 @@ const ClipEdit = () => {
<Playbar <Playbar
video={videoRef.current} video={videoRef.current}
videoMetadata={metadata} videoMetadata={metadata}
className={"w-full accent-primary"} className={"w-full accent-terracotta"}
/> />
<PlaybackSlider <PlaybackSlider
@@ -145,7 +157,7 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
sliderValue={playbackValue} sliderValue={playbackValue}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
className={"w-full accent-primary"} className={"w-full accent-terracotta"}
/> />
<ClipRangeSlider <ClipRangeSlider
@@ -153,11 +165,11 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata} setMetadata={setOutputMetadata}
className={"w-full mt-2 bg-primary"} className={"w-full mt-2"}
/> />
{error && ( {error && (
<div className={"text-red-600 text-center mt-2"}> <div className={"text-error text-center mt-2"}>
{error} {error}
</div> </div>
)} )}
@@ -170,6 +182,7 @@ const ClipEdit = () => {
handleDownload={handleDownload} handleDownload={handleDownload}
downloadable={downloadable} downloadable={downloadable}
progress={progress} progress={progress}
uploading={uploading}
/> />
</Box> </Box>
</div> </div>

View File

@@ -1,12 +1,11 @@
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 navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
const press = () => { const press = () => {
if (!file) { if (!file) {
@@ -17,10 +16,17 @@ const ClipUpload = () => {
}; };
return ( return (
<div className="flex items-start justify-center p-8 min-h-full"> <div className="flex justify-center p-8">
<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">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1> <div
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 <input
type="file" type="file"
onChange={(e) => { onChange={(e) => {
@@ -28,12 +34,22 @@ const ClipUpload = () => {
setFile(selected); setFile(selected);
setError(null); 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" className="hidden"
/> />
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<BlueButton onClick={press}>Upload</BlueButton> <button
onClick={press}
className={"hover:bg-hover p-1 rounded-md"}
>
Upload
</button>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-sm font-body text-center">{error}</p>}
</Box> </Box>
</div> </div>
) )

View File

@@ -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/endpoints.ts"; import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx"; import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
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 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 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-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150" 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} /> <ArrowLeft size={16} />
Back Back
</button> </button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
{loading ? ( {loading ? (
<div className="text-gray-400 text-sm">Loading...</div> <div className="text-muted 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-gray-700">Title</label> <label className="text-sm font-medium text-text-secondary">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-gray-700">Description</label> <label className="text-sm font-medium text-text-secondary">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-red-500">{error}</p>} {error && <p className="text-sm text-error">{error}</p>}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28"> <PrimaryButton 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"}
</BlueButton> </PrimaryButton>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150" className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
> >
Cancel Cancel
</button> </button>

View File

@@ -0,0 +1,108 @@
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;

View File

@@ -3,8 +3,8 @@ import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lu
const features = [ const features = [
{ {
icon: Upload, icon: Upload,
title: "Upload & convert", title: "Upload & compress",
description: "Drop in a video file. Non-native formats are converted automatically before editing.", description: "Drop in a video file, set your clip range and export settings, then compress.",
}, },
{ {
icon: Scissors, icon: Scissors,
@@ -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-semibold text-gray-900 mb-3 leading-tight"> <h1 className="text-4xl font-heading text-text-primary">
A home for your clips. A home for your clips.
</h1> </h1>
<p className="text-base text-gray-500 leading-relaxed"> <p className="text-text-secondary">
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,22 +56,22 @@ const Home = () => {
{features.map(({ icon: Icon, title, description }) => ( {features.map(({ icon: Icon, title, description }) => (
<div <div
key={title} key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm" className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
> >
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
<Icon size={18} className="text-primary" /> <Icon size={18} className="text-terracotta" />
</div> </div>
<div> <div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p> <p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p> <p className="text-sm text-text-secondary leading-relaxed">{description}</p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Format note */} {/* Format note */}
<p className="text-xs text-gray-400"> <p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG. Non-native formats are converted on upload. Supports MP4, MOV, WebM, and OGG.
</p> </p>
</div> </div>
); );

View File

@@ -1,12 +1,27 @@
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 type { Clip } from "../utils/types"; import { AuthError } from "../utils/api/client.ts";
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";
const VideoPlayer = () => { // Both VoDs and clips share the same on-screen shape.
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();
@@ -16,10 +31,11 @@ const VideoPlayer = () => {
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data // Data
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined); const mediaUrl = id ? getMediaUrl(id) : 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 [clip, setClip] = useState<Clip | null>(null); const [item, setItem] = useState<MediaItem | null>(null);
const [timeAgo, setTimeAgo] = useState(""); const [timeAgo, setTimeAgo] = useState("");
// Playback // Playback
@@ -39,28 +55,28 @@ const VideoPlayer = () => {
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("Clip ID is required."); return; } if (!id) { setError(`${noun} ID is required.`); return; }
getVideoBlob(id) fetchDetails(id)
.then((blob) => setVideoUrl(URL.createObjectURL(blob))) .then(setItem)
.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 clip details."); else setError(`Failed to load ${noun.toLowerCase()} details.`);
}); });
}, [id]); }, [id, noun, fetchDetails]);
useEffect(() => { useEffect(() => {
if (!clip?.createdAt) return; if (!item?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt))); const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
update(); update();
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [clip]); }, [item]);
// ── Controls visibility ───────────────────────────────────────────────── // ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => { const scheduleHide = useCallback(() => {
@@ -186,16 +202,16 @@ const VideoPlayer = () => {
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-gray-100 flex items-center justify-center"> <div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
<Lock size={24} className="text-gray-400" /> <Lock size={24} className="text-muted" />
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p> <p className="text-2xl font-heading text-text-primary">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p> <p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div> </div>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150" className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
Go back Go back
@@ -208,7 +224,7 @@ const VideoPlayer = () => {
<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-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150" className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
Back Back
@@ -217,7 +233,7 @@ const VideoPlayer = () => {
{/* Player */} {/* Player */}
<div <div
ref={containerRef} ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none" className="relative bg-text-primary 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}
@@ -226,7 +242,10 @@ const VideoPlayer = () => {
<video <video
ref={videoRef} ref={videoRef}
className="w-full h-full object-contain" className="w-full h-full object-contain"
src={videoUrl} src={mediaUrl}
// 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);
@@ -234,16 +253,19 @@ const VideoPlayer = () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}} }}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }} onLoadedMetadata={() => {
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 && videoUrl && ( {isWaiting && mediaUrl && (
<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-white/20 border-t-white animate-spin" /> <div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
</div> </div>
)} )}
@@ -260,20 +282,20 @@ const VideoPlayer = () => {
{/* Scrub bar */} {/* Scrub bar */}
<div <div
ref={progressRef} ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150" className="relative h-1 rounded-full bg-on-dark/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-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} /> <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-primary rounded-full" style={{ width: `${progressPct}%` }} /> <div className="absolute inset-y-0 left-0 bg-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
<div <div
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" 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"
style={{ left: `calc(${progressPct}% - 6px)` }} style={{ left: `calc(${progressPct}% - 6px)` }}
/> />
</div> </div>
{/* Buttons row */} {/* Buttons row */}
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-on-dark">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors"> <button onClick={togglePlay} className="hover:text-on-dark/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" />}
@@ -281,7 +303,7 @@ const VideoPlayer = () => {
{/* 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-white/70 transition-colors"> <button onClick={toggleMute} className="hover:text-on-dark/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">
@@ -290,18 +312,18 @@ const VideoPlayer = () => {
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-white cursor-pointer" className="w-16 accent-on-dark cursor-pointer"
/> />
</div> </div>
</div> </div>
<span className="text-xs font-mono tabular-nums text-white/80"> <span className="text-xs font-data tabular-nums text-on-dark/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-white/70 transition-colors"> <button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />} {isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
@@ -309,20 +331,20 @@ const VideoPlayer = () => {
</div> </div>
</div> </div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>} {error && <div className="text-error text-sm mt-3">{error}</div>}
{!videoUrl && !error && ( {!mediaLoaded && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2"> <div className="text-muted text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" /> <div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted 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-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p> <p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p> <p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
</Box> </Box>
</div> </div>
); );
}; };
export default VideoPlayer; export default MediaPlayer;

View File

@@ -1,7 +1,7 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
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/endpoints"; import { getClips, deleteClip } from "../utils/api/clips";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
@@ -26,7 +26,7 @@ const MyClips = () => {
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
@@ -43,13 +43,13 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<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-red-500 mt-4">{error.toString()}</p>} {error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
</div> </div>
); );
} }

View File

@@ -0,0 +1,60 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods";
import type { Vod } from "../utils/types";
const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []);
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>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((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)}
/>
))}
</div>
{vods.length === 0 && !error && (
<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;

View File

@@ -0,0 +1,24 @@
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;

View File

@@ -1,18 +1,14 @@
import { useState, useEffect } from "react"; import { useState } 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 { getUser } from "../utils/endpoints.ts"; import { useAuth } from "../auth/useAuth.ts";
import type { User } from "../utils/types.ts"; import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => { const Profile = () => {
const [user, setUser] = useState<User | null>(null); const { user } = useAuth();
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);
@@ -22,7 +18,7 @@ const Profile = () => {
if (!user) { if (!user) {
return ( return (
<div className="flex justify-center items-center h-full text-gray-400 text-base"> <div className="flex justify-center items-center h-full text-muted text-base">
Please log in to view your profile. Please log in to view your profile.
</div> </div>
); );
@@ -30,7 +26,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-2xl font-semibold text-gray-900 mb-6">Profile</h1> <h1 className="text-4xl font-heading text-text-primary 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
@@ -40,31 +36,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-lg font-semibold text-gray-900">{user.name}</p> <p className="text-2xl font-heading text-text-primary">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p> <p className="text-sm text-text-secondary">@{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-sm text-text-secondary">{user.email}</p>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-muted mt-1">
Member since {new Date(user.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })} Member since {formatLocalDate(user.createdAt)}
</p> </p>
</div> </div>
</Box> </Box>
<Box className="p-6"> <Box className="p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2> <h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-text-secondary 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-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden"> <div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong 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-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600" className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
> >
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />} {keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
@@ -72,9 +68,9 @@ const Profile = () => {
<button <button
onClick={handleCopy} onClick={handleCopy}
title="Copy key" title="Copy key"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600" className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
> >
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />} {copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
</button> </button>
</div> </div>
</Box> </Box>

View File

@@ -0,0 +1,395 @@
import { useEffect, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx";
import Box from "../components/Box.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 { formatTime, 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 CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
<div className="flex items-center gap-2.5">
<div className={clsx("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>
);
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 [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
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]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [streamDetails?.startDate]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers(data
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.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 () => {
if (!markerMessage.trim() || markerBusy) return;
setMarkerBusy(true);
setMarkerError(null);
try {
await createMarker(markerMessage.trim());
setMarkerMessage("");
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
} finally {
setMarkerBusy(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("Tap two markers above to pick a start and end");
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 handleChipClick = (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 elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null;
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>
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
<p className={labelClass}>elapsed</p>
</div>
) : (
<Radio size={28} className="text-inactive" />
)}
</Box>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-olive flex-shrink-0" />
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
/>
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</PrimaryButton>
</div>
{markerError && <p className="text-sm text-error">{markerError}</p>}
{markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
return (
<div key={m.id} className="group relative flex-shrink-0">
<button
onClick={() => handleChipClick(m.id)}
className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-terracotta bg-selected ring-1 ring-terracotta",
isEnd && "border-olive bg-selected ring-1 ring-olive",
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover"
)}
>
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
<span className="font-data text-[11px] text-muted">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-terracotta font-medium"> · start</span>}
{isEnd && <span className="text-olive font-medium"> · end</span>}
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
title="Delete marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-error text-on-accent transition-colors duration-150"
>
<X size={10} />
</button>
</div>
);
})}
</div>
)}
</Box>
{/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<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-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 above 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>
</div>
<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>
);
};
export default StreamControl;

View File

@@ -0,0 +1,14 @@
import {config} from "../../config.ts";
const API_URL = config.apiUrl;
class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok;
};
export { API_URL, AuthError, isThumbnailAvailable };

View File

@@ -0,0 +1,61 @@
import type { APIResponse, Clip } from "../types.ts";
import { API_URL, AuthError } from "./client.ts";
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;
};
// 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 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}`);
};
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };

Some files were not shown because too many files have changed in this diff Show More