125 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
9185a41147 feat(frontend): remove server-side upload process and switch to browser process 2026-06-20 22:04:42 +01:00
b05ae62230 refactor(dto): use record for Token DTO 2026-06-20 19:01:59 +01:00
a6850d3d65 refactor(jobs): split JobService into JobRegistryService and JobOrchestrationService 2026-06-20 18:50:14 +01:00
7cee64beb3 refactor(stream): centralize stream directory resolution in DirectoryService 2026-06-20 18:38:47 +01:00
a0efa3eb8c feat(stream): add streaming endpoint and indicator 2026-06-20 16:21:18 +01:00
2b1f8ce710 refactor(dto): move API DTOs 2026-06-20 16:04:19 +01:00
06f392a3ff refactor(dto): use Record instead of Lombok data 2026-06-20 14:54:36 +01:00
07305f0513 fix(frontend): add no auth screen for clips 2026-06-20 14:49:10 +01:00
b84950a2b8 feat(frontend): update home page 2026-06-20 14:38:35 +01:00
637d6c94ed feat(frontend): add edit clip page 2026-06-20 14:30:22 +01:00
bc5c50d025 docs(bruno): add edit endpoint 2026-06-20 14:24:17 +01:00
3797fe1feb feat(frontend): improve video player 2026-06-20 14:24:03 +01:00
fd1b0e19a1 feat(clips): add edit functionality for title & description 2026-06-20 14:23:50 +01:00
8059963210 feat(frontend): order clips by newest first 2026-06-20 14:04:00 +01:00
85d6f568db feat(frontend): add delete option for clips 2026-06-20 13:02:47 +01:00
91c3259331 feat(frontend): restyle and design frontend 2026-06-20 12:52:51 +01:00
1062f72661 refactor(directories): improve error handling 2026-06-20 12:38:40 +01:00
94269459b9 Merge pull request 'RTMP Streaming & User Streams' (#1) from feat/rtmp-streaming into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/1
2026-06-20 01:33:55 +01:00
7eb757d41a chore: remove .mvn 2026-06-20 01:29:48 +01:00
26a906c755 docs: update env & bruno 2026-06-20 01:28:15 +01:00
37e9b6c1e7 refactor(directory): propagate dir creation error to caller 2026-06-20 01:27:51 +01:00
8602f65db7 fix(stream history): add auth missing checks 2026-06-19 23:26:11 +01:00
85615ebc54 fix(schema): mismatch column names for Stream 2026-06-19 23:09:05 +01:00
e60b6e36b2 fix(stream clip): blank section at start of clip 2026-06-19 23:00:09 +01:00
736142e8b3 refactor(stream): clip section extends save section
Add javadocs.
2026-06-19 22:48:53 +01:00
f6061c25ca docs(bruno): add clip endpoint 2026-06-19 22:33:50 +01:00
88d83be50a feat(stream): implement clipping functionality 2026-06-19 22:30:30 +01:00
b55ba3ce51 feat(api): add error handling for save endpoint 2026-06-19 22:04:42 +01:00
5b997cb945 chore(bruno): add save stream endpoint 2026-06-16 21:08:44 +01:00
bf59cb087f feat(stream): implement section saving 2026-06-06 15:22:36 +01:00
480c21bc8c fix(profiles): missing profile page 2026-06-06 13:56:47 +01:00
8bc9044fab feat(frontend): add profile page to view stream key 2026-06-06 13:25:35 +01:00
a928bb390f fix: broken compose configuration 2026-06-04 22:19:45 +01:00
1dbc40d8e6 feat(stream): connect nginx with backend with controllers 2026-05-18 22:27:49 +01:00
3b188a3497 fix(nginx): envs not passed into nginx 2026-05-18 21:14:56 +01:00
c70cd3f939 fix(db): remove auto-drop 2026-05-17 22:30:35 +01:00
6230259582 refactor(nginx): streams are separated by key 2026-05-17 22:05:29 +01:00
781aecbc09 build: media stored in volume 2026-05-17 21:28:21 +01:00
32a016e212 build: update env example 2026-05-17 21:17:03 +01:00
a74e5d1453 build(properties): missing properties for db 2026-05-17 21:16:19 +01:00
a74ab9ee0a fix(frontend): missing URL prefixes 2026-05-17 21:06:13 +01:00
7eb4f1d34e build: move postgres credentials to env 2026-05-17 20:49:04 +01:00
09ec5cf09c fix: incorrect docker deployment 2026-05-14 20:27:27 +01:00
2e6679cb89 refactor(nginx): nginx only handles rtmp connections
Not longer handles frontend & backend. Partially for ease of development and also for separation of concenrs.
2026-05-14 20:20:37 +01:00
265a96acc1 feat: add Stream table & boilerplate 2026-05-03 12:51:32 +01:00
942f0d1a77 fix: broken frontend in docker compose 2026-05-02 19:19:25 +01:00
a20ea862b6 fix: missing dir in gitignore 2026-05-01 23:36:02 +01:00
a9e8b7fe78 feat: route Spring Boot & Vite through nginx 2026-05-01 23:35:04 +01:00
3418267829 feat: add nginx rtmp server & api and frontend passthroughs 2026-05-01 23:34:43 +01:00
207 changed files with 9442 additions and 4237 deletions

View File

@@ -1,3 +1,20 @@
# Secrets
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
JWT_SECRET_KEY=
# Volumes
POSTGRES_DATA=
STREAM_DATA=
MEDIA_DATA=
# DB Credentials
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=vodSystem
FRONTEND_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 }}

4
.gitignore vendored
View File

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

View File

@@ -1,19 +1,3 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
wrapperVersion=3.3.2
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
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:
# VoD System
- Upload clips effortlessly and securely.
- Edit and trim videos with intuitive controls.
- Compress files to specific file sizes.
- Organize your clips for quick access and sharing.
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.
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
- 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
- **Format Handling**
- Backend conversion of non-MP4 files via FFMPEG for broader format support
- **Input Sources**
- Support for local file uploads and YouTube imports
- **Testing**
- Unit tests, including coverage for FFMPEG-related functionality
- **API Documentation**
- Comprehensive and maintainable backend API docs
- Docker and Docker Compose
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
---
## Getting Started
### 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 {
name: Get all clips
type: http
seq: 1
seq: 2
}
get {

View File

@@ -0,0 +1,22 @@
meta {
name: Update Clip
type: http
seq: 3
}
patch {
url: {{base_url}}/clips/5
body: json
auth: inherit
}
body:json {
{
"title": "NOT BZ"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

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

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

@@ -0,0 +1,22 @@
meta {
name: Create Marker
type: http
seq: 3
}
post {
url: {{base_url}}/markers
body: json
auth: inherit
}
body:json {
{
"message": "Nice play"
}
}
settings {
encodeUrl: true
timeout: 0
}

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 {
name: Process
name: Get all markers
type: http
seq: 2
}
get {
url: {{base_url}}/process/{{uuid}}
url: {{base_url}}/markers
body: none
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

@@ -0,0 +1,20 @@
meta {
name: History Streams
type: http
seq: 1
}
get {
url: {{base_url}}/stream/history/1
body: none
auth: bearer
}
auth:bearer {
token: {{token}}
}
settings {
encodeUrl: true
timeout: 0
}

View File

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

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 {
url: {{base_url}}/auth/user
url: {{base_url}}/users/me
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
post {
url: {{base_url}}/auth/login
url: {{base_url}}/users/login
body: json
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,7 @@
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}

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
]

33
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,33 @@
# Local dev: only infrastructure services
# Backend and frontend run directly on the host
# Usage: docker compose -f docker-compose.dev.yml up
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
ports:
- "5432:5432"
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
network_mode: host
env_file:
- .env
environment:
- WEB_SERVER_URL=http://localhost:8080
ports:
- "1935:1935"
volumes:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped

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

@@ -1,16 +1,13 @@
services:
postgres:
image: postgres:15
container_name: vod_postgres
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: vodSystem
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ${POSTGRES_DATA}:/var/lib/postgresql/data
backend:
build:
@@ -21,6 +18,9 @@ services:
- postgres
env_file:
- .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports:
- "8080:8080"
restart: unless-stopped
@@ -30,13 +30,30 @@ services:
context: ./frontend
dockerfile: Dockerfile
container_name: vod_frontend
env_file:
- .env
environment:
VITE_API_URL: ${BACKEND_URL}
VITE_FRONTEND_URL: ${FRONTEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on:
- backend
ports:
- "5173:5173"
- "5173:80"
restart: unless-stopped
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
env_file:
- .env
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes:
postgres_data:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped
depends_on:
- backend

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -1,9 +1,19 @@
FROM node:20-alpine
# --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
RUN npm run build
# --- 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">
<head>
<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" />
<title>Vite + React + TS</title>
<script src="/env-config.js"></script>
<title>The VoD System</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -8,27 +8,21 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"postinstall": "flowbite-react patch"
"preview": "vite preview"
},
"dependencies": {
"@react-oauth/google": "^0.12.2",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"dotenv": "^16.5.0",
"flowbite-react": "^0.11.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0",
"path": "^0.12.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0",
"tailwindcss": "^4.1.7"
"react-router-dom": "^7.8.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
@@ -36,9 +30,9 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tailwindcss": "^4.3.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-plugin-mkcert": "^1.17.8"
"vite": "^6.3.5"
}
}

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

@@ -6,8 +6,18 @@ import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home';
import {useEffect} from "react";
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 EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx";
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() {
@@ -16,7 +26,8 @@ function App() {
}, []);
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<GoogleOAuthProvider clientId={config.googleClientId}>
<AuthProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
@@ -24,10 +35,18 @@ function App() {
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<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="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ 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 React from "react";
type props = {
children: React.ReactNode,
className?: string
}
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({children, className}: props) => {
return (
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
{children}
</div>
)
}
);
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";
type DropdownItemProps = {
item: string;
onClick: (item: string) => void;
className?: string;
}
};
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
return (
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
onClick={() => onClick(item)}
>
{item}
</li>
);
}
type DropdownProps = {
label: string;
label: React.ReactNode;
children: React.ReactNode;
className?: string;
}
};
const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (ref.current && !ref.current.contains(event.target)) {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className={clsx(className, "relative inline-block text-left")}
ref={ref}>
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={toggleDropdown}
className={"inline-flex justify-between w-full rounded-xl px-4 py-2 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50"}
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<ul className={"absolute w-30 origin-top-right rounded-md bg-white shadow-lg font-medium"}>
<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}
</ul>
)}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
);
};
export { Dropdown, DropdownItem };

View File

@@ -1,21 +1,16 @@
import React from "react";
type props = {
type Props = {
children: React.ReactNode;
label: String;
}
label: string;
};
const Selector = ({children, label}: props) => {
return (
<div className={"flex items-center gap-2"}>
<label
className={"w-full"}>
{ label }
</label>
<div className={"w-px h-6 bg-gray-400 mx-3"} />
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-text-secondary">{label}</label>
<div className="w-px h-5 text-text-secondary mx-2" />
{children}
</div>
)
}
);
export default Selector;

View File

@@ -1,42 +1,53 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home } from 'lucide-react';
import Box from "./Box.tsx";
import type {User} from "../utils/types.ts";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import { useAuth } from "../auth/useAuth.ts";
type props = {
user: User | null;
className?: string
}
const Sidebar = ({user, className}: props) => {
return (
<Box className={clsx("h-screen flex flex-col mr-5", className)}>
<img
className={"w-45 mx-auto"}
src={"../../public/logo.png"}
alt={"VoD System Logo"}
/>
<SidebarButton
url={"/"}
logo={<Home size={20}/>}
label={"Home"}
/>
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
<SidebarButton
url={"/create"}
logo={<Plus size={20}/>}
label={"Create Clip"}
/>
const Sidebar = ({className}: props) => {
const { user } = useAuth();
return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
<nav className="flex flex-col flex-1 items-center w-3/4">
<Divider />
<div className="flex flex-col gap-0.5 w-full">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div>
<Divider />
{user && (
<SidebarButton
url={"/my-clips"}
logo={<Film size={20}/>}
label={"My Clips"}
/>
<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-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</div>
)}
</Box>
<div className="flex-1" />
<div className={"w-full"}>
{user && (
<>
<Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</div>
</nav>
</aside>
);
};

View File

@@ -1,32 +1,30 @@
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/endpoints.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
@@ -35,10 +33,6 @@ const Topbar = ({
}
login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => {
console.error("Login failed:", error);
});
@@ -46,33 +40,42 @@ const Topbar = ({
const handleLogout = () => {
logout()
.then(() => {
fetchUser();
navigate("/");
})
.then(() => navigate("/"))
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<div className={clsx(className, "flex justify-between")}>
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div>
<div className={"hover:bg-hover rounded-lg p-0.5"}>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
/>
<Dropdown label={user.name}>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />
className={"text-error font-medium"} />
</Dropdown>
</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-text rounded-lg hover:bg-primary-pressed transition-colors duration-100 h-10 ${className}`}
{...props}
/>
);
};
export default BlueButton;

View File

@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-lg hover:bg-gray-300 hover:text-black transition-colors duration-200", className)}
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
{...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 } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import React from "react";
type props = {
type Props = {
url: string;
logo: React.ReactNode;
label: String;
label: string;
}
const SidebarButton = ({url, logo, label}: props) => {
const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
return (
<Link
className={"w-full"}
to={url}
>
<MenuButton className={"flex items-center gap-2 w-full"}>
<Link className="w-full" to={url}>
<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}
</MenuButton>
</button>
</Link>
)
}

View File

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

View File

@@ -1,46 +1,36 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
type prop = {
setMetadata: Function;
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
}
};
export default function ConfigBox({setMetadata, className}: prop) {
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 }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({
...prevState,
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
};
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fps: parseInt(e.target.value)
}))
}
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fileSize: parseInt(e.target.value) * 1000
}))
}
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
};
return (
<div className={clsx("flex flex-col gap-2 p-10", className)}>
<h2 className={"text-xl font-bold"}>Export</h2>
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
<Selector label={"Resolution"}>
<select id="resolution"
name="resolution"
defaultValue="1280,720"
onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option>
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
</select>
</Selector>
<Selector label={"FPS"}>
<select id="fps"
name="fps"
defaultValue="30"
onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
<option value="15">15</option>
</select>
</Selector>
<Selector label={"File Size Limit (MB)"}>
<input type="number"
min="1"
defaultValue="10"
onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
/>
</Selector>
</div>
)
);
}

View File

@@ -1,33 +1,52 @@
import BlueButton from "../buttons/BlueButton.tsx";
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import React from "react";
type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>;
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
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 (
<div className={"flex flex-col gap-3"}>
<BlueButton
onClick={dataSend}>
Export
</BlueButton>
<PrimaryButton
onClick={dataSend}
disabled={uploading}
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">
{ downloadable ?
(<BlueButton
onClick={handleDownload}>
(<PrimaryButton className="w-full" onClick={handleDownload}>
Download
</BlueButton>)
</PrimaryButton>)
:(
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress> )
className="w-full h-1.5 rounded bg-fields">
</progress>
)
}
</div>
</div>
)
}

View File

@@ -1,28 +1,25 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = {
setMetadata: Function
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
}
};
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<p className={"w-full font-bold text-xl "}>Title</p>
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-body text-text-strong font-bold text-sm">
Title <span className="text-error">*</span>
</p>
<input
type="text"
required
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
/>
</div>
)
}
);
export default MetadataBox;

View File

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

View File

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

View File

@@ -1,79 +1,137 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import {useEffect, useState} from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import React, { useEffect, useRef, useState } from "react";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = {
id: number,
title: string,
duration: number,
createdAt: string,
className?: string
onEdit?: () => void,
onDelete?: () => void,
className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const VideoCard = ({
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const fallbackThumbnail = "public/default_thumbnail.png";
const API_URL = config.apiUrl;
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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
}, 1000);
const [imgFailed, setImgFailed] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => {
isThumbnailAvailable(id)
.then((available) => {
setThumbnailAvailable(available);
})
.catch(() => {
setThumbnailAvailable(false);
});
}, []);
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
setConfirmDelete(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
e.preventDefault();
e.stopPropagation();
fn();
};
return (
<Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}>
<div className={"relative inline-block"}>
<Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
onError={() => setImgFailed(true)}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<p className="
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
<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)}
</p>
</span>
{(onEdit || onDelete) && (
<div
ref={menuRef}
className="absolute top-1.5 right-1.5"
onClick={(e) => e.preventDefault()}
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
>
<Pencil size={13} /> Edit
</button>
)}
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
>
<Trash2 size={13} /> Delete
</button>
)}
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className={"flex flex-col justify-between p-2"}>
<p>{title == "" ? "(No Title)" : title}</p>
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
<div className="flex flex-col p-2">
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title}
</p>
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
</div>
</div>
</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,96 +1,90 @@
@import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme {
--font-display: "Satoshi", "sans-serif";
/* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Breakpoints */
--breakpoint-3xl: 1920px;
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
--color-text-body: #554D44;
--color-text-secondary: #6E6559;
--color-muted: #9A9084;
--color-faint-mono: #A79C8E;
--color-primary-pressed: oklch(0.45 0.21 254);
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-terracotta: #A85A38;
--color-terracotta-hover: #94492A;
--color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
/* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #B5432E;
--color-error: #B5432E;
#range-slider .range-slider__range{
background: var(--color-primary);
}
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
#range-slider .range-slider__thumb{
background: var(--color-primary);
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2;
--color-hover: #E7E0D2;
--color-selected: #F6EFE6;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
}
:root {
font-family: Helvetica;
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*a {*/
/* font-weight: 500;*/
/* color: #646cff;*/
/* text-decoration: inherit;*/
/*}*/
/*a:hover {*/
/* color: #535bf2;*/
/*}*/
body {
background-color: var(--color-background);
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--color-text-body);
}
/*body {*/
/* margin: 0;*/
/* display: flex;*/
/* place-items: center;*/
/* min-width: 320px;*/
/* min-height: 100vh;*/
/*}*/
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
color: var(--color-text-primary);
}
/*h1 {*/
/* font-size: 3.2em;*/
/* line-height: 1.1;*/
/*}*/
/* 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;
}
/*button {*/
/* border-radius: 8px;*/
/* border: 1px solid transparent;*/
/* padding: 0.6em 1.2em;*/
/* font-size: 1em;*/
/* font-weight: 500;*/
/* font-family: inherit;*/
/* background-color: #1a1a1a;*/
/* cursor: pointer;*/
/* transition: border-color 0.25s;*/
/*}*/
/*button:hover {*/
/* border-color: #646cff;*/
/*}*/
/*button:focus,*/
/*button:focus-visible {*/
/* outline: 4px auto -webkit-focus-ring-color;*/
/*}*/
#range-slider .range-slider__range {
background: var(--color-terracotta-light);
}
/*@media (prefers-color-scheme: light) {*/
/* :root {*/
/* color: #213547;*/
/* background-color: #ffffff;*/
/* }*/
/* a:hover {*/
/* color: #747bff;*/
/* }*/
/* button {*/
/* background-color: #f9f9f9;*/
/* }*/
/*}*/
#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,38 +3,32 @@ import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import type {User} from "../utils/types";
import { getUser } from "../utils/endpoints";
import { useAuth } from "../auth/useAuth";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
useEffect(() => {
fetchUser();
}, []);
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
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
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}/>
<div className="overflow-auto">
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />
</div>
</div>

View File

@@ -1,23 +1,30 @@
import { useParams } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints"
import { compress } from "../utils/api/media"
import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
import {config} from "../config.ts";
const API_URL = config.apiUrl;
const ClipEdit = () => {
const { id } = useParams();
const location = useLocation();
const localFile: File = location.state?.file;
const videoRef = useRef<HTMLVideoElement | null>(null);
const videoUrl = `/api/v1/download/input/${id}`
const [videoUrl, setVideoUrl] = useState<string>('');
const [uploadedId, setUploadedId] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "",
description: "",
startPoint: 0,
@@ -29,43 +36,82 @@ const ClipEdit = () => {
});
const [progress, setProgress] = useState(0);
const [downloadable, setDownloadable] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
const handleVideoMetadataLoaded = () => {
const video = videoRef.current;
if (!video) return;
setMetadata({
title: '',
description: '',
startPoint: 0,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
fps: 30,
fileSize: localFile?.size ?? 0,
});
};
const sendData = async () => {
if (!id) return;
if (!localFile || uploading) return;
editFile(id, outputMetadata)
.then(() => {
processFile(id)
.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(id, interval), 500);
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.process.progress);
setUploading(true);
setError(null);
if (progress.process.complete) {
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 (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
setUploading(false);
} else if (job.isComplete) {
clearInterval(intervalId);
setDownloadable(true);
} else {
setDownloadable(false)
setUploading(false);
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
setUploading(false);
});
}
};
const handleDownload = async () => {
const response = await fetch(`/api/v1/download/output/${id}`);
if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) {
console.error('Download failed');
@@ -77,49 +123,33 @@ const ClipEdit = () => {
const a = document.createElement('a');
a.href = url;
a.download = `${id}.mp4`;
a.download = `${uploadedId}.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
useEffect(() => {
if (!id) return;
getMetadata(id)
.then((data) => setMetadata(data ?? null))
.catch((err) => console.error("Metadata fetch failed:", err));
}, [id]);
return (
<div className={"grid grid-cols-[7fr_3fr]"}>
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video
ref={videoRef}
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
<source src={videoUrl} type="video/ogg" />
Your browser does not support the video tag. Bzzzz.
</video>
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-text-primary"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Box className={"w-4/5 h-full m-auto"}>
<MetadataBox
setMetadata={setOutputMetadata}
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Box>
{metadata &&
<Box className={"mt-4 p-5"}>
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className={"w-full accent-primary"}
className={"w-full accent-terracotta"}
/>
<PlaybackSlider
@@ -127,7 +157,7 @@ const ClipEdit = () => {
videoMetadata={metadata}
sliderValue={playbackValue}
setSliderValue={setPlaybackValue}
className={"w-full accent-primary"}
className={"w-full accent-terracotta"}
/>
<ClipRangeSlider
@@ -135,11 +165,11 @@ const ClipEdit = () => {
videoMetadata={metadata}
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2 bg-primary"}
className={"w-full mt-2"}
/>
{error && (
<div className={"text-red-600 text-center mt-2"}>
<div className={"text-error text-center mt-2"}>
{error}
</div>
)}
@@ -152,10 +182,11 @@ const ClipEdit = () => {
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</Box>
</div>
);
}
};
export default ClipEdit;

View File

@@ -1,97 +1,57 @@
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx";
const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const navigate = useNavigate();
const isVideoFileSupported = (file: File): boolean => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => {
const press = () => {
if (!file) {
setError("Please choose a file");
return;
}
uploadFile(file)
.then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
navigate('/create/new', { state: { file } });
};
return (
<Box className={"flex flex-col justify-between gap-3 p-5"}>
<div className="flex justify-center p-8">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<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
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"}
className="hidden"
/>
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<BlueButton
onClick={press}>
<button
onClick={press}
className={"hover:bg-hover p-1 rounded-md"}
>
Upload
</BlueButton>
</button>
<label className={"text-center text-red-500"}>{error}</label>
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
{error && <p className="text-sm font-body text-center">{error}</p>}
</Box>
</div>
)
};

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 { getClipById, patchClip } from "../utils/api/clips.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 EditClip = () => {
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;
getClipById(id)
.then((clip) => {
if (clip) {
setTitle(clip.title ?? "");
setDescription(clip.description ?? "");
}
})
.catch(() => setError("Failed to load clip."))
.finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
setError(null);
try {
await patchClip(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 Clip</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 EditClip;

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

@@ -1,43 +1,78 @@
const Home = () => {
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
const features = [
{
icon: Upload,
title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
},
{
icon: Scissors,
title: "Trim",
description: "Set start and end points with a range slider. Preview as you go.",
},
{
icon: SlidersHorizontal,
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.",
},
{
icon: Radio,
title: "RTMP streaming",
description: "Point OBS at your stream key and go live. Find the key in your profile.",
},
{
icon: LayoutGrid,
title: "Clip library",
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
},
{
icon: Play,
title: "Built-in player",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
},
];
const Home = () => {
return (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
{/* Logo */}
<div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img
src="/logo.png"
alt="VoD System Logo"
className={"h-44 mb-8 object-contain"}
className="h-24 mb-6 object-contain"
/>
{/* Description Container */}
<div className={"max-w-xl text-center"}>
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
What is the VoD System?
</h2>
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
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:
</p>
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
<li>Upload clips effortlessly and securely.</li>
<li>Edit and trim videos with intuitive controls.</li>
<li>Compress files to specific file sizes.</li>
<li>Organize your clips for quick access and sharing.</li>
</ul>
<p className={"text-lg leading-relaxed text-gray-600"}>
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
making video clip management faster and more enjoyable than ever.
<h1 className="text-4xl font-heading text-text-primary">
A home for your clips.
</h1>
<p className="text-text-secondary">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
{/* File Support Note */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}>
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
Support for additional video formats will be added in future updates.
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
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 flex items-center justify-center shrink-0">
<Icon size={18} className="text-terracotta" />
</div>
<div>
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG.
</p>
</div>
);
};

View File

@@ -0,0 +1,350 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import { AuthError } from "../utils/api/client.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// 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 navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data
const mediaUrl = id ? getMediaUrl(id) : undefined;
const [mediaLoaded, setMediaLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
const [item, setItem] = useState<MediaItem | null>(null);
const [timeAgo, setTimeAgo] = useState("");
// Playback
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
// Volume
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// UI
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
// ── 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(() => {
if (!id) { setError(`${noun} ID is required.`); return; }
fetchDetails(id)
.then(setItem)
.catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`);
});
}, [id, noun, fetchDetails]);
useEffect(() => {
if (!item?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(item.createdAt)));
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [item]);
// ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
}, []);
const showControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
document.fullscreenElement
? document.exitFullscreen()
: containerRef.current.requestFullscreen();
}, []);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const video = videoRef.current;
if (!video) return;
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
video.paused ? video.play() : video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showControls();
break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
}, [duration]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
const onUp = () => setIsDragging(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isDragging, seekTo]);
// ── Video element event handlers ────────────────────────────────────────
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime);
if (video.buffered.length > 0) {
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
if (notAuthenticated) {
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
<Lock size={24} className="text-muted" />
</div>
<div>
<p className="text-2xl font-heading text-text-primary">Not authenticated</p>
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
>
<ArrowLeft size={16} />
Go back
</button>
</div>
);
}
return (
<div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
{/* Player */}
<div
ref={containerRef}
className="relative bg-text-primary rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
src={mediaUrl}
// Send auth cookies with the cross-origin Range requests.
crossOrigin="use-credentials"
preload="metadata"
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => {
setIsPlaying(false);
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => {
if (videoRef.current) setDuration(videoRef.current.duration);
setMediaLoaded(true);
}}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/>
{/* Buffering spinner */}
{isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
{/* Controls bar — pointer-events-auto only here */}
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-on-dark/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-on-dark">
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-on-dark cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-data tabular-nums text-on-dark/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
</div>
{error && <div className="text-error text-sm mt-3">{error}</div>}
{!mediaLoaded && !error && (
<div className="text-muted text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
</Box>
</div>
);
};
export default MediaPlayer;

View File

@@ -1,31 +1,55 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { getClips } from "../utils/endpoints";
import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips";
import type { Clip } from "../utils/types";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
getClips()
.then((data) => setClips(data))
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err));
}, []);
const handleDelete = async (id: number) => {
try {
await deleteClip(id);
setClips(prev => prev.filter(c => c.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete clip");
}
};
return (
<div className={"flex flex-row"}>
<div className="p-6">
<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">
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
className={"w-40 m-5"}
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
))}
</div>
{error}
{clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
</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

@@ -0,0 +1,81 @@
import { useState } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
import { useAuth } from "../auth/useAuth.ts";
import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => {
const { user } = useAuth();
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = () => {
if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
Please log in to view your profile.
</div>
);
}
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
<Box className="p-6 mb-4 flex items-center gap-5">
<img
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt="Profile picture"
className="w-20 h-20 rounded-full shadow-sm"
/>
<div>
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
<p className="text-sm text-text-secondary">@{user.username}</p>
<p className="text-sm text-text-secondary">{user.email}</p>
<p className="text-xs text-muted mt-1">
Member since {formatLocalDate(user.createdAt)}
</p>
</div>
</Box>
<Box className="p-6">
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
<p className="text-sm text-text-secondary mb-4">
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.
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
</div>
<button
onClick={() => setKeyVisible(v => !v)}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<button
onClick={handleCopy}
title="Copy key"
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
</button>
</div>
</Box>
</div>
);
};
export default Profile;

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;

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