143 Commits

Author SHA1 Message Date
13869c577b wip
Some checks failed
CI / Maven unit tests (push) Failing after 12s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-09-20 20:37:56 +01:00
bd3d99e801 fix(segment-concat): out-of-bounds exception during media concat
All checks were successful
CI / Maven unit tests (push) Successful in 1m4s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
Caused by the duration exceeding 1, which caused CommandRunner to throw.
2026-09-16 20:59:57 +01:00
7dacb7a53a feat(frontend): make markers list vertical in stream page (#46)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 44s
CI / Deploy to server (push) Successful in 9s
Resolves #38

Reviewed-on: #46
2026-09-16 20:11:12 +01:00
d81202f905 fix(streaming): hitches in VoDs & Clips resulting from ffmpeg concatenation (#44)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 2m8s
CI / Deploy to server (push) Successful in 17s
- Resolves #43

Reviewed-on: #44
2026-09-16 19:49:04 +01:00
9758bb296b fix(frontend): logout state not shown after JWT expires (#40)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 57s
CI / Deploy to server (push) Successful in 8s
Reviewed-on: #40
2026-09-06 23:26:07 +01:00
6fa02a34a3 feat(frontend): swap dark-mode brown colours for a black palette (#39)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 58s
CI / Deploy to server (push) Successful in 13s
Reviewed-on: #39
2026-09-06 19:59:13 +01:00
ccf56f767f chore: remove default vite readme
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Successful in 50s
CI / Deploy to server (push) Successful in 18s
2026-09-05 11:11:15 +01:00
7f58bad245 feat(frontend): add dark mode option (#34)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 48s
CI / Deploy to server (push) Successful in 19s
Resolves #32

Reviewed-on: #34
2026-08-30 17:48:59 +01:00
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
fe097c8ee7 build: add comprehensive docker files for frontend & backend
Update docker-compose.yml to include new Dockerfiles
2026-02-22 21:38:43 +00:00
ab943be44e refactor(frontend): move media blob fetch function to endpoint file 2026-02-22 21:38:08 +00:00
0c5b93571b chore: add .env to gitignore 2026-02-22 21:37:07 +00:00
f2596e75f0 fix(api): incorrect local CORS url 2026-02-22 01:00:57 +00:00
7c95f87b40 chore: remove HTTPS module in frontend 2026-02-22 00:45:41 +00:00
Dylan De Faoite
83aa6e230d Update README to remove user management section
Removed user management plans from the README as these have been implemented
2026-01-08 11:09:58 +00:00
eed46cf266 Extract database clip saving into saveClip()
persistClip() had grown too large and handled multiple concerns.
This change moves the database save logic into saveClip(), making
persistClip() smaller and easier to refactor further.
2025-12-15 22:06:57 +00:00
65ec8cb29a MOVE clip creation method from ClipService to JobService
The original clip creation method in ClipService handled many different services, like metadata handling, callback methods and more. Moving it to a more suitable location in the JobService allows it to easily access the Job object and update it upon failure.
2025-12-15 21:23:01 +00:00
0f5fc76e55 ADD unified metadata validation to the MetadataService
Validation was happening in two places, in both EditService and in MetadataService doing different validations. This unifies them both into a singular method
2025-12-15 21:19:53 +00:00
2d0d168784 ADD information-level logging to EditService 2025-12-15 20:28:27 +00:00
215 changed files with 9765 additions and 4295 deletions

View File

@@ -1,3 +1,20 @@
# Secrets
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
JWT_SECRET_KEY= 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 }}

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
### Security ### ### Security ###
.env.local .env.local
.env.prod .env.prod
.env
node_modules node_modules
HELP.md HELP.md
@@ -38,6 +39,8 @@ build/
.vscode/ .vscode/
### Generated Files ### ### Generated Files ###
videos/ /videos
/outputs
frontend/.flowbite-react
generated generated

View File

@@ -1,19 +1,3 @@
# Licensed to the Apache Software Foundation (ASF) under one wrapperVersion=3.3.4
# 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
distributionType=only-script 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 distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

146
README.md
View File

@@ -1,37 +1,121 @@
# Overview # VoD System
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
- Upload clips effortlessly and securely. A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker.
- Edit and trim videos with intuitive controls.
- Compress files to specific file sizes.
- Organize your clips for quick access and sharing.
This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend. This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming.
---
## Tech Stack
| Layer | Technology |
|------------|---------------------------------------------------------|
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
| Database | PostgreSQL 15 |
| Streaming | nginx-rtmp |
| Auth | Google OAuth 2.0 + JWT |
| Runtime | Docker Compose |
---
## Requirements ## Requirements
- Docker & Docker Compose
- JDK 21+
- Node.js 16+
- FFMPEG
- Google Client keys
## Steps
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
2. Spin up docker container with `docker compose up`
3. Run `./mvnw clean package` to build the jar
4. Run `./mvnw spring-boot:run` to run the backend section
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
# Future Plans - Docker and Docker Compose
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
- **User Management** That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
- Database integration with authentication and login
- Clip saving and management for authenticated users ---
- **Format Handling**
- Backend conversion of non-MP4 files via FFMPEG for broader format support ## Getting Started
- **Input Sources**
- Support for local file uploads and YouTube imports ### 1. Configure the environment
- **Testing**
- Unit tests, including coverage for FFMPEG-related functionality Copy the example file and fill in the values:
- **API Documentation**
- Comprehensive and maintainable backend API docs ```bash
cp .env.example .env
```
Open `.env` and set the following:
```dotenv
# OAuth credentials from your Google Cloud Console project
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# A long random string used to sign JWTs — treat it like a password
JWT_SECRET_KEY=
# Absolute paths on the host where persistent data will be stored
POSTGRES_DATA=/path/to/postgres/data
STREAM_DATA=/path/to/stream/data
MEDIA_DATA=/path/to/media/data
# Database credentials — change these from the defaults before deploying
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=vodSystem
# The URLs the frontend will use to reach itself and the backend
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8080
```
### 2. Start the application
```bash
docker compose up --build
```
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
Once everything has started, the interfaces are available at:
- Frontend: [http://localhost:5173](http://localhost:5173)
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
- RTMP ingest: `rtmp://localhost:1935/live`
### 3. Authenticate
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
---
## Development Mode
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
```bash
docker compose -f docker-compose.dev.yml up
```
Then run the backend and frontend separately in your usual workflow.
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
---
## API Reference
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
The collection is organised into four folders:
| Folder | Description |
|----------|--------------------------------------------------|
| Users | Google OAuth login flow, current user |
| Media | Upload, compress, clip stream, save section |
| Jobs | Poll job status, download completed output |
| Streams | Stream history |
---
## Future Plans
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
- **Input sources** — YouTube imports and additional local upload options
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
- **API documentation** — comprehensive, auto-generated backend API docs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
meta {
name: Save Stream Section By Markers
type: http
seq: 4
}
post {
url: {{base_url}}/media/save/markers
body: json
auth: inherit
}
body:json {
{
"startMarkerId": 1,
"endMarkerId": 2,
"title": "Hello",
"description": "my clip"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
meta {
name: Update VoD
type: http
seq: 3
}
patch {
url: {{base_url}}/vods/1
body: json
auth: inherit
}
body:json {
{
"title": "My VoD",
"description": ""
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

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

View File

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

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

@@ -0,0 +1,34 @@
# 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
env_file:
- .env
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- WEB_SERVER_URL=http://host.docker.internal: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,18 +1,59 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: my_postgres container_name: vod_postgres
environment: environment:
POSTGRES_USER: myuser POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: mypassword POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: vodSystem POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - ${POSTGRES_DATA}:/var/lib/postgresql/data
backend:
build:
context: .
dockerfile: src/main/Dockerfile
container_name: vod_backend
depends_on:
- postgres
env_file:
- .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports:
- "8080:8080"
restart: unless-stopped
volumes: frontend:
postgres_data: build:
context: ./frontend
dockerfile: Dockerfile
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:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
env_file:
- .env
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped
depends_on:
- backend

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

19
frontend/Dockerfile Normal file
View File

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

View File

@@ -1,54 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

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,29 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/small_icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <script>
// Apply the stored theme before first paint so a dark session never
// flashes light. Mirrors the logic in src/theme/ThemeProvider.tsx.
(function () {
try {
var stored = localStorage.getItem("theme");
var dark = stored === "dark" ||
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
<script src="/env-config.js"></script>
<title>The VoD System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,4 +1,4 @@
// App.jsx // App.jsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import ClipUpload from './pages/ClipUpload'; import ClipUpload from './pages/ClipUpload';
@@ -6,8 +6,20 @@ import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home'; import Home from './pages/Home';
import {useEffect} from "react"; import {useEffect} from "react";
import MyClips from './pages/MyClips'; import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx"; import MyVods from './pages/MyVods';
import MediaPlayer from "./pages/MediaPlayer.tsx";
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
import EditClip from "./pages/EditClip.tsx";
import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
import ThemeProvider from "./theme/ThemeProvider.tsx";
import RequireAuth from "./auth/RequireAuth.tsx";
function App() { function App() {
@@ -16,18 +28,33 @@ function App() {
}, []); }, []);
return ( return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}> <GoogleOAuthProvider clientId={config.googleClientId}>
<Router> <ThemeProvider>
<Routes> <AuthProvider>
<Route element={<MainLayout />}> <Router>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/create" element={<ClipUpload />} /> <Route element={<MainLayout />}>
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/" element={<Home />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/video/:id" element={<VideoPlayer />} /> <Route path="/create/:id" element={<ClipEdit />} />
</Route>
</Routes> <Route element={<RequireAuth />}>
</Router> <Route path="/my-clips" element={<MyClips />} />
<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>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
</ GoogleOAuthProvider> </ GoogleOAuthProvider>
); );
} }

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,20 @@
import { createContext } from "react";
import type { User } from "../utils/types.ts";
type AuthContextValue = {
/** The currently authenticated user, or null when logged out. */
user: User | null;
/** True until the initial "who am I" request has resolved. */
loading: boolean;
/** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

@@ -0,0 +1,51 @@
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";
import {registerUnauthorizedHandler} from "../utils/api/client.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();
await refresh();
}, [refresh]);
useEffect(() => {
registerUnauthorizedHandler(() => setUser(null));
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

@@ -0,0 +1,14 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "./useAuth";
const RequireAuth = () => {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return null;
if (!user) return <Navigate to="/" replace state={{ from: location }} />;
return <Outlet />;
};
export default RequireAuth;

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { AuthContext } from "./AuthContext.ts";
import type { AuthContextValue } from "./AuthContext.ts";
/** Access the centralised auth state. Must be used within an <AuthProvider>. */
const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export { useAuth };

View File

@@ -1,16 +1,15 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
type props = { type Props = {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
} };
const Box = ({children, className}: props) => { const Box = ({ children, className }: Props) => (
return ( <div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}> {children}
{ children } </div>
</div> );
)
}
export default Box; export default Box;

View File

@@ -0,0 +1,18 @@
import type { ElementType } from "react";
type Props = {
icon: ElementType;
title: string;
accent?: "primary" | "accent";
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: Props) => (
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${accent === "primary" ? "bg-terracotta/10" : "bg-olive/10"}`}>
<Icon size={16} className={accent === "primary" ? "text-terracotta" : "text-olive"} />
</div>
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
</div>
);
export default CardHeader;

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,32 @@
import { Menu, X } from 'lucide-react'; import { Menu, X, Sun, Moon } from 'lucide-react';
import { login, logout } from "../utils/endpoints.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx"; import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google'; import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google'; import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx"; import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx"; import clsx from "clsx";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
import { useTheme } from "../theme/useTheme.ts";
type props = { type props = {
sidebarToggled: boolean; sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void; setSidebarToggled: (toggled: boolean) => void;
user: User | null; isStreaming: boolean;
fetchUser: () => void;
className?: string; className?: string;
} }
const Topbar = ({ const Topbar = ({
sidebarToggled, sidebarToggled,
setSidebarToggled, setSidebarToggled,
user, isStreaming,
fetchUser,
className}: props) => { className}: props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, login, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const handleLogin = (response: CredentialResponse) => { const handleLogin = (response: CredentialResponse) => {
if (!response.credential) { if (!response.credential) {
@@ -35,53 +35,71 @@ const Topbar = ({
} }
login(response.credential) login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => { .catch((error) => {
console.error("Login failed:", error); console.error("Login failed:", error);
}); });
} }
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
const handleLogout = () => { const handleLogout = () => {
logout() logout()
.then(() => { .then(() => navigate("/"))
fetchUser();
navigate("/");
})
.catch((error) => { .catch((error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);
}); });
} }
return ( return (
<div className={clsx(className, "flex justify-between")}> <div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}> <MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>} {sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton> </MenuButton>
{ user ? ( <div className="flex items-center gap-1">
<div> <MenuButton
onClick={toggleTheme}
title={themeLabel}
aria-label={themeLabel}
>
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
</MenuButton>
{ user ? (
<div className={"hover:bg-hover rounded-lg p-0.5"}>
<img <img
className={"w-8 h-8 rounded-full inline-block"} className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl} src={user.profilePictureUrl}
referrerPolicy="no-referrer" 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" <DropdownItem item="Logout"
onClick={() => handleLogout()} onClick={() => handleLogout()}
className={"text-red-500 font-medium"} /> className={"text-error font-medium"} />
</Dropdown> </Dropdown>
</div> </div>
) : ) :
( (
<GoogleLogin <GoogleLogin
shape={"pill"} shape={"pill"}
theme={theme === "dark" ? "filled_black" : "outline"}
useOneTap={false} useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} /> onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)} )}
</div>
</div> </div>
) )
} }

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 }) => { const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return ( return (
<button <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} {...props}
/> />
); );

View File

@@ -0,0 +1,14 @@
import React from "react";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const PrimaryButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={`bg-terracotta text-on-accent font-body text-sm rounded-lg hover:bg-terracotta-hover disabled:bg-terracotta-disabled transition-colors duration-150 h-10 px-4 ${className}`}
{...props}
/>
);
};
export default PrimaryButton;

View File

@@ -1,21 +1,27 @@
import MenuButton from "./MenuButton.tsx"; import { Link, useLocation } from "react-router-dom";
import { Link } from "react-router-dom"; import React from "react";
type props = { type Props = {
url: string; url: string;
logo: React.ReactNode; logo: React.ReactNode;
label: String; label: string;
} }
const SidebarButton = ({url, logo, label}: props) => { const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
return ( return (
<Link <Link className="w-full" to={url}>
className={"w-full"} <button className={`flex items-center gap-2.5 w-full text-sm
to={url} font-body p-2 hover:bg-hover hover:text-text-strong
> rounded-md
<MenuButton className={"flex items-center gap-2 w-full"}> ${isActive ?
"bg-card text-text-strong font-medium shadow-sm"
:
"text-text-primary"}`}>
{logo}{label} {logo}{label}
</MenuButton> </button>
</Link> </Link>
) )
} }

View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import { Bookmark, Plus, X } from "lucide-react";
import clsx from "clsx";
import Box from "../Box.tsx";
import CardHeader from "../CardHeader.tsx";
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import type { Marker } from "../../utils/types.ts";
import { formatLocalTime } from "../../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
type RailSegment = "none" | "plain" | "lit";
const railClass = (segment: RailSegment) => clsx(
"w-px",
segment === "lit" && "bg-terracotta",
segment === "plain" && "bg-inactive-strong/60",
segment === "none" && "bg-transparent"
);
type MarkerRowProps = {
marker: Marker;
role: "start" | "end" | "between" | null;
/** Rail segments above and below the dot — "lit" inside the chosen section. */
railAbove: RailSegment;
railBelow: RailSegment;
onSelect: () => void;
onDelete: () => void;
};
const MarkerRow = ({ marker, role, railAbove, railBelow, onSelect, onDelete }: MarkerRowProps) => (
<li className="group relative flex gap-3">
{/* Timeline rail: a dot per marker, with the segment between the chosen
start and end lit so the selected section reads as one run. */}
<div className="flex w-2.5 flex-col items-center flex-shrink-0">
<span className={clsx("h-3.5 flex-shrink-0", railClass(railAbove))} />
<span className={clsx(
"h-2.5 w-2.5 rounded-full border-2 flex-shrink-0 transition-colors duration-150",
role === "start" && "border-terracotta bg-terracotta",
role === "end" && "border-olive bg-olive",
role === "between" && "border-terracotta bg-card",
role === null && "border-inactive-strong bg-card"
)} />
<span className={clsx("flex-1", railClass(railBelow))} />
</div>
{/* The rail spans the full row, so the breathing room goes on the button —
the line stays continuous while neighbouring rings no longer touch. */}
<button
onClick={onSelect}
className={clsx(
"min-w-0 flex-1 rounded-md my-0.5 py-2 pl-2.5 pr-7 text-left transition-colors duration-150",
role === "start" && "bg-selected ring-1 ring-terracotta",
role === "end" && "bg-selected ring-1 ring-olive",
(role === "between" || role === null) && "hover:bg-hover"
)}
>
<p className="truncate text-sm font-medium text-text-strong">{marker.message}</p>
<p className="font-data text-[11px] text-muted tabular-nums">
{formatLocalTime(marker.timestamp)}
{role === "start" && <span className="font-medium text-terracotta"> · start</span>}
{role === "end" && <span className="font-medium text-olive"> · end</span>}
</p>
</button>
<button
onClick={onDelete}
title="Delete marker"
aria-label={`Delete marker ${marker.message}`}
className="absolute right-1 top-2.5 flex h-5 w-5 items-center justify-center rounded-md text-muted opacity-0 transition duration-150 hover:bg-hover hover:text-error focus-visible:opacity-100 group-hover:opacity-100"
>
<X size={12} />
</button>
</li>
);
type Props = {
/** Newest first — the list renders them in the order given. */
markers: Marker[];
startMarkerId: string;
endMarkerId: string;
error: string | null;
/** Resolves true once the marker exists, so the input only clears on success. */
onAdd: (message: string) => Promise<boolean>;
onSelect: (id: number) => void;
onDelete: (id: number) => void;
className?: string;
};
const MarkerList = ({ markers, startMarkerId, endMarkerId, error, onAdd, onSelect, onDelete, className }: Props) => {
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const handleAdd = async () => {
if (!message.trim() || busy) return;
setBusy(true);
if (await onAdd(message.trim())) setMessage("");
setBusy(false);
};
// Either of the two may have been picked first, so the lit run is bounded by
// whichever indices come first/last rather than by start/end.
const startIndex = markers.findIndex((m) => String(m.id) === startMarkerId);
const endIndex = markers.findIndex((m) => String(m.id) === endMarkerId);
const hasRange = startIndex !== -1 && endIndex !== -1;
const rangeFrom = hasRange ? Math.min(startIndex, endIndex) : -1;
const rangeTo = hasRange ? Math.max(startIndex, endIndex) : -1;
// The rail segment sitting between marker `i` and the one after it.
const railSegment = (i: number): RailSegment => {
if (i < 0 || i >= markers.length - 1) return "none";
return hasRange && i >= rangeFrom && i < rangeTo ? "lit" : "plain";
};
return (
<Box className={clsx("p-5 flex flex-col gap-4", className)}>
<div className="flex items-center justify-between gap-3">
<CardHeader icon={Bookmark} title="Markers" accent="accent" />
{markers.length > 0 && (
<span className="font-data text-[11px] text-muted tabular-nums">{markers.length}</span>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="What just happened?"
className={inputClass}
/>
<PrimaryButton
onClick={handleAdd}
disabled={busy || !message.trim()}
className="flex items-center gap-1.5 px-3 whitespace-nowrap"
>
<Plus size={15} />
Add
</PrimaryButton>
</div>
{error && <p className="text-sm text-error">{error}</p>}
</div>
{markers.length === 0 ? (
<p className="text-sm text-muted">
Nothing marked yet. Markers stack up here, newest first — pick two to save the section between them.
</p>
) : (
/* py-0.5 keeps the scroll container from clipping the selection
ring on the first and last rows. */
<ul className="-mr-1 flex max-h-[26rem] flex-col overflow-y-auto py-0.5 pr-1">
{markers.map((m, i) => (
<MarkerRow
key={m.id}
marker={m}
role={String(m.id) === startMarkerId ? "start"
: String(m.id) === endMarkerId ? "end"
: hasRange && i > rangeFrom && i < rangeTo ? "between"
: null}
railAbove={railSegment(i - 1)}
railBelow={railSegment(i)}
onSelect={() => onSelect(m.id)}
onDelete={() => onDelete(m.id)}
/>
))}
</ul>
)}
</Box>
);
};
export default MarkerList;

View File

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

View File

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

View File

@@ -1,34 +1,53 @@
import BlueButton from "../buttons/BlueButton.tsx"; import PrimaryButton from "../buttons/PrimaryButton.tsx";
import React from "react"; import React from "react";
type props = { type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>; dataSend: React.MouseEventHandler<HTMLButtonElement>;
handleDownload: React.MouseEventHandler<HTMLButtonElement>; handleDownload: React.MouseEventHandler<HTMLButtonElement>;
downloadable: boolean; downloadable: boolean;
progress: number progress: number;
uploading: boolean;
} }
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => { const Spinner = () => (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return ( return (
<div className={"flex flex-col gap-3"}> <div className={"flex flex-col gap-3"}>
<BlueButton <PrimaryButton
onClick={dataSend}> onClick={dataSend}
Export disabled={uploading}
</BlueButton> className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</PrimaryButton>
{ downloadable ? <div className="h-10 flex items-center">
(<BlueButton { downloadable ?
onClick={handleDownload}> (<PrimaryButton className="w-full" onClick={handleDownload}>
Download Download
</BlueButton>) </PrimaryButton>)
:( :(
<progress <progress
value={progress} value={progress}
className={"bg-gray-300 rounded-lg h-1"}> className="w-full h-1.5 rounded bg-fields">
</progress> ) </progress>
} )
}
</div>
</div> </div>
) )
} }
export default ExportWidget; export default ExportWidget;

View File

@@ -1,28 +1,25 @@
import clsx from "clsx"; import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = { type Props = {
setMetadata: Function setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => { const MetadataBox = ({ setMetadata, className }: Props) => (
return ( <div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}> <p className="w-full font-body text-text-strong font-bold text-sm">
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/} Title <span className="text-error">*</span>
</p>
<input
type="text"
required
placeholder="Enter title"
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>
);
<p className={"w-full font-bold text-xl "}>Title</p> export default MetadataBox;
<input
type="text"
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"}
/>
</div>
)
}
export default MetadataBox;

View File

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

View File

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

View File

@@ -1,79 +1,137 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import {useEffect, useState} from "react"; import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
className?: string onEdit?: () => void,
onDelete?: () => void,
className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "public/default_thumbnail.png";
const API_URL = config.apiUrl;
const VideoCard = ({
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [imgFailed, setImgFailed] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
setTimeout(() => { const [confirmDelete, setConfirmDelete] = useState(false);
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))) const menuRef = useRef<HTMLDivElement>(null);
}, 1000); const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) const interval = setInterval(() => {
.then((available) => { setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
setThumbnailAvailable(available); }, 1000);
}) return () => clearInterval(interval);
.catch(() => { }, [createdAt]);
setThumbnailAvailable(false);
});
}, []);
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 ( return (
<Link to={"/video/" + id}> <Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col", className)}> <div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className={"relative inline-block"}> <div className="relative overflow-hidden rounded-lg">
<img <img
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail} src={imgFailed ? fallbackThumbnail : thumbnailUrl}
onError={() => setImgFailed(true)}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />
<p className=" <span className="absolute top-1.5 left-1.5 bg-media-backdrop/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
{formatTime(duration)} {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-media-backdrop/75 hover:bg-media-backdrop/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>
<div className="flex flex-col p-2">
<div className={"flex flex-col justify-between p-2"}> <p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
<p>{title == "" ? "(No Title)" : title}</p> {title === "" ? "(No Title)" : title}
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
</p> </p>
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
</div> </div>
</div> </div>
</Link> </Link>

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

@@ -0,0 +1,5 @@
export const config = {
apiUrl: window._env_?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
frontendUrl: window._env_?.VITE_FRONTEND_URL ?? import.meta.env.VITE_FRONTEND_URL ?? '',
googleClientId: window._env_?.VITE_GOOGLE_CLIENT_ID ?? import.meta.env.VITE_GOOGLE_CLIENT_ID ?? '',
};

View File

@@ -1,96 +1,159 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
/* Drive the `dark:` variant off the same data-theme attribute the toggle sets,
instead of Tailwind's default prefers-color-scheme. Without this a stray
`dark:` utility would disagree with an explicit light/dark choice. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme { @theme {
--font-display: "Satoshi", "sans-serif"; /* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Breakpoints */ /* ── Surfaces ────────────────────────────────────────────────────────── */
--breakpoint-3xl: 1920px; --color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */ /* Letterbox behind video. Dark in both themes — it is a cinema surface,
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */ not an ink colour — so `text-on-dark` always reads on top of it. */
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */ --color-media-backdrop: #2B2620;
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
--color-primary-pressed: oklch(0.45 0.21 254); /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
--color-text-body: #554D44;
--color-text-secondary: #6E6559;
--color-muted: #9A9084;
--color-faint-mono: #A79C8E;
/* Easing */ /* ── Accents ─────────────────────────────────────────────────────────── */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --color-terracotta: #A85A38;
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); --color-terracotta-hover: #94492A;
--color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #B5432E;
--color-error: #B5432E;
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2;
--color-hover: #E7E0D2;
--color-selected: #F6EFE6;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
} }
#range-slider .range-slider__range{ /* Dark mode. Only token values change here: every component already styles
background: var(--color-primary); itself with the semantic utilities above, so the whole UI follows from this
} one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes. */
:root[data-theme="dark"] {
color-scheme: dark;
#range-slider .range-slider__thumb{ /* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
background: var(--color-primary); --color-background: #0E0E10;
--color-sidebar: #09090B;
--color-card: #17171A;
--color-fields: #121215;
--color-dropzone: #141417;
--color-media-backdrop: #000000;
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F4F4F5;
--color-text-strong: #E3E3E7;
--color-text-body: #BFBFC6;
--color-text-secondary: #94949D;
--color-muted: #76767F;
--color-faint-mono: #6A6A73;
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
rather than deepens ─────────────────────────────────────────────────── */
--color-terracotta: #E07A4C;
--color-terracotta-hover: #F08F62;
--color-terracotta-light: #F0A177;
--color-terracotta-disabled: #5E463A;
--color-olive: #A3B678;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #F2604A;
--color-error: #F2604A;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #17100B;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #232327;
--color-hover: #26262B;
--color-selected: #2A1F18;
--color-ring: #33333A;
--color-inactive: #2C2C32;
--color-inactive-strong: #55555E;
} }
:root { :root {
font-family: Helvetica; font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none;
font-synthesis: none; text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
-moz-osx-font-smoothing: grayscale; color-scheme: light;
} }
/*a {*/ body {
/* font-weight: 500;*/ background-color: var(--color-background);
/* color: #646cff;*/ font-family: "IBM Plex Sans", system-ui, sans-serif;
/* text-decoration: inherit;*/ color: var(--color-text-body);
/*}*/ }
/*a:hover {*/
/* color: #535bf2;*/
/*}*/
/*body {*/ @utility font-heading {
/* margin: 0;*/ font-family: "Newsreader", serif;
/* display: flex;*/ font-weight: 400;
/* place-items: center;*/ color: var(--color-text-primary);
/* min-width: 320px;*/ }
/* min-height: 100vh;*/
/*}*/
/*h1 {*/ /* Google Identity Services renders its sign-in button in a cross-origin iframe
/* font-size: 3.2em;*/ whose document is light-scheme with a transparent background. Left to inherit
/* line-height: 1.1;*/ our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
/*}*/ white canvas behind it instead — framing the pill in a white box on every dark
page. Pinning the iframe back to light keeps it transparent; the button's own
colours come from the `theme` prop in Topbar.tsx. */
iframe[src*="accounts.google.com/gsi/"] {
color-scheme: light;
}
/*button {*/ /* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
/* border-radius: 8px;*/ defaults; the #id beats those class selectors regardless of import order. */
/* border: 1px solid transparent;*/ #range-slider {
/* padding: 0.6em 1.2em;*/ height: 8px;
/* font-size: 1em;*/ background: var(--color-fields);
/* font-weight: 500;*/ border-radius: 8px;
/* 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;*/
/*}*/
/*@media (prefers-color-scheme: light) {*/ #range-slider .range-slider__range {
/* :root {*/ background: var(--color-terracotta-light);
/* color: #213547;*/ }
/* background-color: #ffffff;*/
/* }*/ #range-slider .range-slider__thumb {
/* a:hover {*/ width: 18px;
/* color: #747bff;*/ height: 18px;
/* }*/ background: var(--color-terracotta);
/* button {*/ box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
/* background-color: #f9f9f9;*/ }
/* }*/
/*}*/ #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,41 +3,35 @@ import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar' import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import type {User} from "../utils/types"; import { useAuth } from "../auth/useAuth";
import { getUser } from "../utils/endpoints"; import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false); const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null); const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
useEffect(() => { useEffect(() => {
fetchUser(); if (!user) return;
}, []); const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar <Topbar
className={"transition-all duration-300"} className={"transition-all duration-300"}
sidebarToggled={sidebarToggled} sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled} setSidebarToggled={setSidebarToggled}
user={user} isStreaming={isStreaming}/>
fetchUser={fetchUser}/> <div className="overflow-auto bg-background">
<div className="overflow-auto">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
}; };
export default MainLayout; export default MainLayout;

View File

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

View File

@@ -1,97 +1,57 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; 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"; import Box from "../components/Box.tsx";
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0); const navigate = useNavigate();
const isVideoFileSupported = (file: File): boolean => { const press = () => {
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 = (() => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Please choose a file");
return; return;
} }
navigate('/create/new', { state: { file } });
};
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);
});
}
return ( return (
<Box className={"flex flex-col justify-between gap-3 p-5"}> <div className="flex justify-center p-8">
<input <Box className="flex flex-col gap-4 p-6 w-full max-w-md">
type="file" <div
onChange={(e) => { className="cursor-pointer rounded-lg border-2
const selected = e.target.files?.[0] ?? null; border-dashed border-hairline bg-dropzone
setFile(selected); text-center text-sm text-text-secondary
}} hover:bg-hover transition-colors duration-150
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"} min-h-50 flex flex-col items-center justify-center gap-2"
/> >
<label className="cursor-pointer flex flex-col items-center justify-center gap-2 w-full h-full">
Choose file
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className="hidden"
/>
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<BlueButton <button
onClick={press}> onClick={press}
Upload className={"hover:bg-hover p-1 rounded-md"}
</BlueButton> >
Upload
</button>
<label className={"text-center text-red-500"}>{error}</label> {error && <p className="text-sm font-body text-center">{error}</p>}
<progress </Box>
value={progress} </div>
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
</Box>
) )
}; };

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 @@
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 = () => { const Home = () => {
return ( return (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}> <div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Logo */} {/* Hero */}
<img <div className="flex flex-col items-center text-center max-w-2xl mb-14">
src="/logo.png" <img
alt="VoD System Logo" src="/logo.png"
className={"h-44 mb-8 object-contain"} alt="VoD System Logo"
/> className="h-24 mb-6 object-contain"
/>
{/* Description Container */} <h1 className="text-4xl font-heading text-text-primary">
<div className={"max-w-xl text-center"}> A home for your clips.
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}> </h1>
What is the VoD System? <p className="text-text-secondary">
</h2> Upload, trim, and export video clips. Stream live if you need to.
<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.
</p> </p>
</div> </div>
{/* File Support Note */} {/* Feature grid */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing. {features.map(({ icon: Icon, title, description }) => (
Support for additional video formats will be added in future updates. <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> </div>
{/* Format note */}
<p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </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-media-backdrop 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 VideoCard from "../components/video/VideoCard";
import {useEffect, useState} from "react"; 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"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() 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)); .catch((err) => setError(err));
}, []); }, []);
return ( const handleDelete = async (id: number) => {
<div className={"flex flex-row"}> try {
{clips.map((clip) => ( await deleteClip(id);
<VideoCard setClips(prev => prev.filter(c => c.id !== id));
id={clip.id} } catch (err) {
title={clip.title} setError(err instanceof Error ? err.message : "Failed to delete clip");
duration={clip.duration} }
createdAt={clip.createdAt} };
className={"w-40 m-5"}
/>
))}
{error} return (
<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}
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
))}
</div>
{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> </div>
); );
} }

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