Compare commits
143 Commits
decf2703bd
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
| 13869c577b | |||
| bd3d99e801 | |||
| 7dacb7a53a | |||
| d81202f905 | |||
| 9758bb296b | |||
| 6fa02a34a3 | |||
| ccf56f767f | |||
| 7f58bad245 | |||
| ec9a982fe9 | |||
| 38942776df | |||
| bf5fe6a35d | |||
| 9033261165 | |||
| 9e82dc8574 | |||
| 1c098028ab | |||
| 9c6002fe77 | |||
| 2f33101ec3 | |||
| da38e21935 | |||
| 3db16cd380 | |||
| 29bc60d417 | |||
| 92cecb1084 | |||
| ae8f2f33a7 | |||
| 74a9d5752d | |||
| fe25ef05dc | |||
| 339465b417 | |||
| 3a46048b66 | |||
| 740f904d92 | |||
| e902d54a4d | |||
| d621aa3d6a | |||
| f9b055b3b5 | |||
| bb6f5e7823 | |||
| 2408263988 | |||
| 688ff541da | |||
| 07ee112d57 | |||
| fbb5c3ab52 | |||
| e1e6fe0046 | |||
| a66b9898e8 | |||
| baaf162fee | |||
| d7214ca1bc | |||
| 5bebea3f95 | |||
| 947c8261fd | |||
| e0969a1d78 | |||
| fe735faba8 | |||
| b8b4d3b05c | |||
| a051407c1e | |||
| 41fda53817 | |||
| 1a0778d1d6 | |||
| ba378680c6 | |||
| 2cac1770f9 | |||
| dc33d1df33 | |||
| 62dc0b0491 | |||
| 18626a5db8 | |||
| 3c8df9b328 | |||
| 76869c8c90 | |||
| e5fcf9b966 | |||
| 4ec5080edc | |||
| 14e8bd137b | |||
| 998eebb032 | |||
| e2c94d0740 | |||
| 0b929e96da | |||
| 1fff0805f6 | |||
| 48ff0eb8e5 | |||
| 83fcb44670 | |||
| 913c5c8790 | |||
| a330471bec | |||
| d8b61b4528 | |||
| 85ddd93757 | |||
| 0b2c875889 | |||
| 7db25194bc | |||
| 3d57007074 | |||
| 5e00965baa | |||
| 0e13e44feb | |||
| 42294703ae | |||
| 755a18fc3a | |||
| 8b299dc4fa | |||
| 5651aa4ce9 | |||
| 8b49a10a8c | |||
| e36913d671 | |||
| 7d8a62d331 | |||
| c571e12099 | |||
| e9abdfb44a | |||
| dc5af8f868 | |||
| da6bf16d07 | |||
| b732c76aef | |||
| 6c1f1112dc | |||
| 9185a41147 | |||
| b05ae62230 | |||
| a6850d3d65 | |||
| 7cee64beb3 | |||
| a0efa3eb8c | |||
| 2b1f8ce710 | |||
| 06f392a3ff | |||
| 07305f0513 | |||
| b84950a2b8 | |||
| 637d6c94ed | |||
| bc5c50d025 | |||
| 3797fe1feb | |||
| fd1b0e19a1 | |||
| 8059963210 | |||
| 85d6f568db | |||
| 91c3259331 | |||
| 1062f72661 | |||
| 94269459b9 | |||
| 7eb757d41a | |||
| 26a906c755 | |||
| 37e9b6c1e7 | |||
| 8602f65db7 | |||
| 85615ebc54 | |||
| e60b6e36b2 | |||
| 736142e8b3 | |||
| f6061c25ca | |||
| 88d83be50a | |||
| b55ba3ce51 | |||
| 5b997cb945 | |||
| bf59cb087f | |||
| 480c21bc8c | |||
| 8bc9044fab | |||
| a928bb390f | |||
| 1dbc40d8e6 | |||
| 3b188a3497 | |||
| c70cd3f939 | |||
| 6230259582 | |||
| 781aecbc09 | |||
| 32a016e212 | |||
| a74e5d1453 | |||
| a74ab9ee0a | |||
| 7eb4f1d34e | |||
| 09ec5cf09c | |||
| 2e6679cb89 | |||
| 265a96acc1 | |||
| 942f0d1a77 | |||
| a20ea862b6 | |||
| a9e8b7fe78 | |||
| 3418267829 | |||
| fe097c8ee7 | |||
| ab943be44e | |||
| 0c5b93571b | |||
| f2596e75f0 | |||
| 7c95f87b40 | |||
|
|
83aa6e230d | ||
| eed46cf266 | |||
| 65ec8cb29a | |||
| 0f5fc76e55 | |||
| 2d0d168784 |
17
.env.example
17
.env.example
@@ -1,3 +1,20 @@
|
||||
# Secrets
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
JWT_SECRET_KEY=
|
||||
|
||||
# Volumes
|
||||
POSTGRES_DATA=
|
||||
STREAM_DATA=
|
||||
MEDIA_DATA=
|
||||
|
||||
# DB Credentials
|
||||
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
||||
POSTGRES_USER=myuser
|
||||
POSTGRES_PASSWORD=mypassword
|
||||
POSTGRES_DB=vodSystem
|
||||
|
||||
FRONTEND_URL=
|
||||
BACKEND_URL=
|
||||
|
||||
STREAM_RING_LENGTH_SECOND=28800
|
||||
118
.gitea/workflows/ci.yml
Normal file
118
.gitea/workflows/ci.yml
Normal 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
5
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
### Security ###
|
||||
.env.local
|
||||
.env.prod
|
||||
.env
|
||||
|
||||
node_modules
|
||||
HELP.md
|
||||
@@ -38,6 +39,8 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Generated Files ###
|
||||
videos/
|
||||
/videos
|
||||
/outputs
|
||||
frontend/.flowbite-react
|
||||
|
||||
generated
|
||||
|
||||
18
.mvn/wrapper/maven-wrapper.properties
vendored
18
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,19 +1,3 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
wrapperVersion=3.3.2
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
|
||||
|
||||
146
README.md
146
README.md
@@ -1,37 +1,121 @@
|
||||
# Overview
|
||||
The VoD System is a powerful clip management platform designed to streamline how you handle your video content. Whether you're a content creator, streamer, or educator, VoD System lets you:
|
||||
# VoD System
|
||||
|
||||
- Upload clips effortlessly and securely.
|
||||
- Edit and trim videos with intuitive controls.
|
||||
- Compress files to specific file sizes.
|
||||
- Organize your clips for quick access and sharing.
|
||||
A self-hosted clip management and live streaming platform. Upload videos, trim and compress clips, save sections of a live stream, and organise everything through a clean web interface — all running locally under Docker.
|
||||
|
||||
This VoD system began as a small project back in my 5th year of secondary school, during lockdown. I wanted a quick way to send game clips to Discord, but the 8MB file size limit made it frustrating to manually tweak bitrates for every clip. To solve that, I wrote a simple Python script to automate the process. What started as a quick fix has since evolved into a full-fledged system — now built with a Spring Boot backend and a modern React/Vite frontend.
|
||||
This project started in secondary school during lockdown as a Python script to squeeze game clips under Discord's 8 MB file size limit. It has since grown into a full-stack application with a Spring Boot backend, a React/Vite/TypeScript frontend, and an nginx-rtmp sidecar for live streaming.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|------------|---------------------------------------------------------|
|
||||
| Backend | Java 21, Spring Boot 3.4, Spring Security, JPA, FFmpeg |
|
||||
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
|
||||
| Database | PostgreSQL 15 |
|
||||
| Streaming | nginx-rtmp |
|
||||
| Auth | Google OAuth 2.0 + JWT |
|
||||
| Runtime | Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
- Docker & Docker Compose
|
||||
- JDK 21+
|
||||
- Node.js 16+
|
||||
- FFMPEG
|
||||
- Google Client keys
|
||||
## Steps
|
||||
1. Create your own two `.env` files based off the `example.env` files in the root and in the frontend folder
|
||||
2. Spin up docker container with `docker compose up`
|
||||
3. Run `./mvnw clean package` to build the jar
|
||||
4. Run `./mvnw spring-boot:run` to run the backend section
|
||||
5. Run `cd frontend && npm install && npm run dev` to build and run the frontend
|
||||
6. Endpoints should be available at 8080 (backend) and 5173 (frontend)
|
||||
|
||||
# Future Plans
|
||||
- Docker and Docker Compose
|
||||
- A Google Cloud project with OAuth 2.0 credentials (Client ID and Secret)
|
||||
|
||||
- **User Management**
|
||||
- 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
|
||||
- **Input Sources**
|
||||
- Support for local file uploads and YouTube imports
|
||||
- **Testing**
|
||||
- Unit tests, including coverage for FFMPEG-related functionality
|
||||
- **API Documentation**
|
||||
- Comprehensive and maintainable backend API docs
|
||||
That is all. The backend, frontend, database, and RTMP server all run as containers — no local JDK, Node, or FFmpeg install required.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Configure the environment
|
||||
|
||||
Copy the example file and fill in the values:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set the following:
|
||||
|
||||
```dotenv
|
||||
# OAuth credentials from your Google Cloud Console project
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# A long random string used to sign JWTs — treat it like a password
|
||||
JWT_SECRET_KEY=
|
||||
|
||||
# Absolute paths on the host where persistent data will be stored
|
||||
POSTGRES_DATA=/path/to/postgres/data
|
||||
STREAM_DATA=/path/to/stream/data
|
||||
MEDIA_DATA=/path/to/media/data
|
||||
|
||||
# Database credentials — change these from the defaults before deploying
|
||||
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
|
||||
POSTGRES_USER=myuser
|
||||
POSTGRES_PASSWORD=mypassword
|
||||
POSTGRES_DB=vodSystem
|
||||
|
||||
# The URLs the frontend will use to reach itself and the backend
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
BACKEND_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
### 2. Start the application
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This builds and starts four containers: `vod_postgres`, `vod_backend`, `vod_frontend`, and `vod_rtmp`.
|
||||
|
||||
Once everything has started, the interfaces are available at:
|
||||
|
||||
- Frontend: [http://localhost:5173](http://localhost:5173)
|
||||
- Backend API: [http://localhost:8080/api/v1](http://localhost:8080/api/v1)
|
||||
- RTMP ingest: `rtmp://localhost:1935/live`
|
||||
|
||||
### 3. Authenticate
|
||||
|
||||
Log in through the frontend using your Google account. The backend exchanges the Google ID token for a short-lived JWT that is used on all subsequent API requests.
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
For local backend or frontend development, a separate Compose file is provided that starts only the infrastructure services (PostgreSQL and the RTMP server), leaving the backend and frontend to run directly on the host:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Then run the backend and frontend separately in your usual workflow.
|
||||
|
||||
- **Important**: Rootless docker should be used to avoid permission conflicts between the RTMP container and the Spring Boot host
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
A [Bruno](https://www.usebruno.com/) collection is included under `bruno/VoD-System/` with pre-configured requests covering all major endpoints. Import it directly into Bruno and point the `local` environment at `http://localhost:8080/api/v1`.
|
||||
|
||||
The collection is organised into four folders:
|
||||
|
||||
| Folder | Description |
|
||||
|----------|--------------------------------------------------|
|
||||
| Users | Google OAuth login flow, current user |
|
||||
| Media | Upload, compress, clip stream, save section |
|
||||
| Jobs | Poll job status, download completed output |
|
||||
| Streams | Stream history |
|
||||
|
||||
---
|
||||
|
||||
## Future Plans
|
||||
|
||||
- **Format handling** — backend conversion of non-MP4 files via FFmpeg for broader input format support
|
||||
- **Input sources** — YouTube imports and additional local upload options
|
||||
- **Testing** — unit and integration test coverage, including FFmpeg-related functionality
|
||||
- **API documentation** — comprehensive, auto-generated backend API docs
|
||||
|
||||
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal file
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal 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
|
||||
}
|
||||
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal file
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal 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
|
||||
}
|
||||
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal file
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Downloads
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
16
bruno/VoD-System/Clips/Get Clip.bru
Normal file
16
bruno/VoD-System/Clips/Get Clip.bru
Normal 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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Get all clips
|
||||
type: http
|
||||
seq: 1
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
|
||||
22
bruno/VoD-System/Clips/Update Clip.bru
Normal file
22
bruno/VoD-System/Clips/Update Clip.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/Clips/folder.bru
Normal file
8
bruno/VoD-System/Clips/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Clips
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Converted Download
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/download/output/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Original Download
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/download/input/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Download
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Progress
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/progress/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Edit
|
||||
}
|
||||
15
bruno/VoD-System/Jobs/Download Video.bru
Normal file
15
bruno/VoD-System/Jobs/Download Video.bru
Normal 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
|
||||
}
|
||||
16
bruno/VoD-System/Jobs/Get Job.bru
Normal file
16
bruno/VoD-System/Jobs/Get Job.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/Jobs/folder.bru
Normal file
8
bruno/VoD-System/Jobs/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Jobs
|
||||
seq: 5
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
22
bruno/VoD-System/Markers/Create Marker.bru
Normal file
22
bruno/VoD-System/Markers/Create Marker.bru
Normal 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
|
||||
}
|
||||
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal file
16
bruno/VoD-System/Markers/Delete Marker.bru
Normal 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
|
||||
}
|
||||
16
bruno/VoD-System/Markers/Get Marker.bru
Normal file
16
bruno/VoD-System/Markers/Get Marker.bru
Normal 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
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
meta {
|
||||
name: Process
|
||||
name: Get all markers
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/process/{{uuid}}
|
||||
url: {{base_url}}/markers
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
8
bruno/VoD-System/Markers/folder.bru
Normal file
8
bruno/VoD-System/Markers/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Markers
|
||||
seq: 6
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
23
bruno/VoD-System/Media/Clip Stream.bru
Normal file
23
bruno/VoD-System/Media/Clip Stream.bru
Normal 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);
|
||||
}
|
||||
22
bruno/VoD-System/Media/Compress.bru
Normal file
22
bruno/VoD-System/Media/Compress.bru
Normal 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);
|
||||
}
|
||||
24
bruno/VoD-System/Media/Save Stream Section By Markers.bru
Normal file
24
bruno/VoD-System/Media/Save Stream Section By Markers.bru
Normal 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);
|
||||
}
|
||||
24
bruno/VoD-System/Media/Save Stream Section.bru
Normal file
24
bruno/VoD-System/Media/Save Stream Section.bru
Normal 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);
|
||||
}
|
||||
8
bruno/VoD-System/Media/folder.bru
Normal file
8
bruno/VoD-System/Media/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Media
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Converted Metadata
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/metadata/converted/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Original Metadata
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/metadata/original/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Metadata
|
||||
}
|
||||
20
bruno/VoD-System/Streams/History Streams.bru
Normal file
20
bruno/VoD-System/Streams/History Streams.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/Streams/folder.bru
Normal file
8
bruno/VoD-System/Streams/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Streams
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/auth/user
|
||||
url: {{base_url}}/users/me
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/auth/login
|
||||
url: {{base_url}}/users/login
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
8
bruno/VoD-System/Users/folder.bru
Normal file
8
bruno/VoD-System/Users/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Users
|
||||
seq: 3
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
16
bruno/VoD-System/VoDs/Delete VoD.bru
Normal file
16
bruno/VoD-System/VoDs/Delete VoD.bru
Normal 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
|
||||
}
|
||||
16
bruno/VoD-System/VoDs/Downloads/Get VoD Media.bru
Normal file
16
bruno/VoD-System/VoDs/Downloads/Get VoD Media.bru
Normal 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
|
||||
}
|
||||
15
bruno/VoD-System/VoDs/Downloads/Get VoD Thumbnail.bru
Normal file
15
bruno/VoD-System/VoDs/Downloads/Get VoD Thumbnail.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/VoDs/Downloads/folder.bru
Normal file
8
bruno/VoD-System/VoDs/Downloads/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Downloads
|
||||
seq: 5
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
16
bruno/VoD-System/VoDs/Get VoD.bru
Normal file
16
bruno/VoD-System/VoDs/Get VoD.bru
Normal 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
|
||||
}
|
||||
11
bruno/VoD-System/VoDs/Get all vods.bru
Normal file
11
bruno/VoD-System/VoDs/Get all vods.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Get all vods
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/vods
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
23
bruno/VoD-System/VoDs/Update VoD.bru
Normal file
23
bruno/VoD-System/VoDs/Update VoD.bru
Normal 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
|
||||
}
|
||||
8
bruno/VoD-System/VoDs/folder.bru
Normal file
8
bruno/VoD-System/VoDs/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: VoDs
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
7
bruno/VoD-System/collection.bru
Normal file
7
bruno/VoD-System/collection.bru
Normal file
@@ -0,0 +1,7 @@
|
||||
auth {
|
||||
mode: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{token}}
|
||||
}
|
||||
8
bruno/VoD-System/environments/dev.bru
Normal file
8
bruno/VoD-System/environments/dev.bru
Normal 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
34
docker-compose.dev.yml
Normal 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
57
docker-compose.prod.yml
Normal 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
|
||||
@@ -1,18 +1,59 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: my_postgres
|
||||
container_name: vod_postgres
|
||||
environment:
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
POSTGRES_DB: vodSystem
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- ${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:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
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:
|
||||
postgres_data:
|
||||
- ${STREAM_DATA}:/stream
|
||||
pull_policy: build
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
1
frontend/.dockerignore
Normal file
1
frontend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal 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;"]
|
||||
@@ -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
10
frontend/env.sh
Normal 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
|
||||
15
frontend/frontend_nginx.conf
Normal file
15
frontend/frontend_nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,29 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/small_icon.png" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<script>
|
||||
// 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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
3543
frontend/package-lock.json
generated
3543
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,27 +8,21 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"postinstall": "flowbite-react patch"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-oauth/google": "^0.12.2",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"flowbite-react": "^0.11.8",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.511.0",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-range-slider-input": "^3.2.1",
|
||||
"react-router-dom": "^7.8.0",
|
||||
"tailwindcss": "^4.1.7"
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
@@ -36,9 +30,9 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-mkcert": "^1.17.8"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/small_icon.png
Normal file
BIN
frontend/public/small_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -6,8 +6,20 @@ import ClipEdit from './pages/ClipEdit';
|
||||
import Home from './pages/Home';
|
||||
import {useEffect} from "react";
|
||||
import MyClips from './pages/MyClips';
|
||||
import VideoPlayer from "./pages/VideoPlayer.tsx";
|
||||
import MyVods from './pages/MyVods';
|
||||
import MediaPlayer from "./pages/MediaPlayer.tsx";
|
||||
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
|
||||
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
|
||||
import EditClip from "./pages/EditClip.tsx";
|
||||
import EditVod from "./pages/EditVod.tsx";
|
||||
import Profile from "./pages/Profile.tsx";
|
||||
import StreamControl from "./pages/StreamControl.tsx";
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
import {config} from "./config.ts";
|
||||
import NotFound from "./pages/NotFound.tsx";
|
||||
import AuthProvider from "./auth/AuthProvider.tsx";
|
||||
import ThemeProvider from "./theme/ThemeProvider.tsx";
|
||||
import RequireAuth from "./auth/RequireAuth.tsx";
|
||||
|
||||
|
||||
function App() {
|
||||
@@ -16,18 +28,33 @@ function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
|
||||
<GoogleOAuthProvider clientId={config.googleClientId}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/create" element={<ClipUpload />} />
|
||||
<Route path="/create/:id" element={<ClipEdit />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/my-clips" element={<MyClips />} />
|
||||
<Route path="/video/:id" element={<VideoPlayer />} />
|
||||
<Route path="/my-vods" element={<MyVods />} />
|
||||
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
|
||||
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
|
||||
<Route path="/clips/:id/edit" element={<EditClip />} />
|
||||
<Route path="/vods/:id/edit" element={<EditVod />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/stream" element={<StreamControl />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ GoogleOAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 |
20
frontend/src/auth/AuthContext.ts
Normal file
20
frontend/src/auth/AuthContext.ts
Normal 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 };
|
||||
51
frontend/src/auth/AuthProvider.tsx
Normal file
51
frontend/src/auth/AuthProvider.tsx
Normal 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;
|
||||
14
frontend/src/auth/RequireAuth.tsx
Normal file
14
frontend/src/auth/RequireAuth.tsx
Normal 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;
|
||||
14
frontend/src/auth/useAuth.ts
Normal file
14
frontend/src/auth/useAuth.ts
Normal 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 };
|
||||
@@ -1,16 +1,15 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
type props = {
|
||||
children: React.ReactNode,
|
||||
className?: string
|
||||
}
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Box = ({children, className}: props) => {
|
||||
return (
|
||||
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
|
||||
const Box = ({ children, className }: Props) => (
|
||||
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
export default Box;
|
||||
18
frontend/src/components/CardHeader.tsx
Normal file
18
frontend/src/components/CardHeader.tsx
Normal 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;
|
||||
@@ -1,60 +1,51 @@
|
||||
import React, {useEffect, useRef} from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type DropdownItemProps = {
|
||||
item: string;
|
||||
onClick: (item: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
|
||||
return (
|
||||
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
|
||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||
<li
|
||||
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownProps = {
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const Dropdown = ({ label, children, className }: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: { target: any }) {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "relative inline-block text-left")}
|
||||
ref={ref}>
|
||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className={"inline-flex justify-between w-full rounded-xl px-4 py-2 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50"}
|
||||
onClick={() => setIsOpen(v => !v)}
|
||||
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className={"absolute w-30 origin-top-right rounded-md bg-white shadow-lg font-medium"}>
|
||||
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-background border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
||||
{children}
|
||||
</ul>
|
||||
)}
|
||||
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export { Dropdown, DropdownItem };
|
||||
@@ -1,21 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
type props = {
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
label: String;
|
||||
}
|
||||
label: string;
|
||||
};
|
||||
|
||||
const Selector = ({children, label}: props) => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<label
|
||||
className={"w-full"}>
|
||||
{ label }
|
||||
</label>
|
||||
<div className={"w-px h-6 bg-gray-400 mx-3"} />
|
||||
const Selector = ({ children, label }: Props) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-full text-sm text-text-secondary">{label}</label>
|
||||
<div className="w-px h-5 text-text-secondary mx-2" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
export default Selector;
|
||||
@@ -1,42 +1,53 @@
|
||||
import clsx from "clsx";
|
||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||
import { Plus, Film, Home } from 'lucide-react';
|
||||
import Box from "./Box.tsx";
|
||||
import type {User} from "../utils/types.ts";
|
||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
|
||||
type props = {
|
||||
user: User | null;
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Sidebar = ({user, className}: props) => {
|
||||
return (
|
||||
<Box className={clsx("h-screen flex flex-col mr-5", className)}>
|
||||
<img
|
||||
className={"w-45 mx-auto"}
|
||||
src={"../../public/logo.png"}
|
||||
alt={"VoD System Logo"}
|
||||
/>
|
||||
<SidebarButton
|
||||
url={"/"}
|
||||
logo={<Home size={20}/>}
|
||||
label={"Home"}
|
||||
/>
|
||||
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
|
||||
|
||||
<SidebarButton
|
||||
url={"/create"}
|
||||
logo={<Plus size={20}/>}
|
||||
label={"Create Clip"}
|
||||
/>
|
||||
const Sidebar = ({className}: props) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
||||
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
|
||||
|
||||
<nav className="flex flex-col flex-1 items-center w-3/4">
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
||||
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{user && (
|
||||
<SidebarButton
|
||||
url={"/my-clips"}
|
||||
logo={<Film size={20}/>}
|
||||
label={"My Clips"}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div>
|
||||
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className={"w-full"}>
|
||||
{user && (
|
||||
<>
|
||||
<Divider />
|
||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { login, logout } from "../utils/endpoints.ts";
|
||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
||||
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
|
||||
import { GoogleLogin } from '@react-oauth/google';
|
||||
|
||||
import type { User } from "../utils/types.ts";
|
||||
import type { CredentialResponse } from '@react-oauth/google';
|
||||
|
||||
import MenuButton from "./buttons/MenuButton.tsx";
|
||||
import clsx from "clsx";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import { useAuth } from "../auth/useAuth.ts";
|
||||
import { useTheme } from "../theme/useTheme.ts";
|
||||
|
||||
|
||||
type props = {
|
||||
sidebarToggled: boolean;
|
||||
setSidebarToggled: (toggled: boolean) => void;
|
||||
user: User | null;
|
||||
fetchUser: () => void;
|
||||
isStreaming: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Topbar = ({
|
||||
sidebarToggled,
|
||||
setSidebarToggled,
|
||||
user,
|
||||
fetchUser,
|
||||
isStreaming,
|
||||
className}: props) => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user, login, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const handleLogin = (response: CredentialResponse) => {
|
||||
if (!response.credential) {
|
||||
@@ -35,54 +35,72 @@ const Topbar = ({
|
||||
}
|
||||
|
||||
login(response.credential)
|
||||
.then(() => {
|
||||
fetchUser();
|
||||
navigate(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Login failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
.then(() => {
|
||||
fetchUser();
|
||||
navigate("/");
|
||||
})
|
||||
.then(() => navigate("/"))
|
||||
.catch((error) => {
|
||||
console.error("Logout failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(className, "flex justify-between")}>
|
||||
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
|
||||
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||
</MenuButton>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<MenuButton
|
||||
onClick={toggleTheme}
|
||||
title={themeLabel}
|
||||
aria-label={themeLabel}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={20}/> : <Moon size={20}/>}
|
||||
</MenuButton>
|
||||
|
||||
{ user ? (
|
||||
<div>
|
||||
<div className={"hover:bg-hover rounded-lg p-0.5"}>
|
||||
<img
|
||||
className={"w-8 h-8 rounded-full inline-block"}
|
||||
src={user.profilePictureUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
<Dropdown label={user.name}>
|
||||
<Dropdown label={
|
||||
<span className="flex items-center gap-1.5">
|
||||
{user.name}
|
||||
{isStreaming && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}>
|
||||
<DropdownItem item="Profile"
|
||||
onClick={() => navigate("/profile")} />
|
||||
<DropdownItem item="Logout"
|
||||
onClick={() => handleLogout()}
|
||||
className={"text-red-500 font-medium"} />
|
||||
className={"text-error font-medium"} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
) :
|
||||
(
|
||||
<GoogleLogin
|
||||
shape={"pill"}
|
||||
theme={theme === "dark" ? "filled_black" : "outline"}
|
||||
useOneTap={false}
|
||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx("p-2 rounded-lg hover:bg-gray-300 hover:text-black transition-colors duration-200", className)}
|
||||
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal file
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal 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;
|
||||
@@ -1,21 +1,27 @@
|
||||
import MenuButton from "./MenuButton.tsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import React from "react";
|
||||
|
||||
type props = {
|
||||
type Props = {
|
||||
url: string;
|
||||
logo: React.ReactNode;
|
||||
label: String;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SidebarButton = ({url, logo, label}: props) => {
|
||||
const SidebarButton = ({url, logo, label}: Props) => {
|
||||
const { pathname } = useLocation();
|
||||
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={"w-full"}
|
||||
to={url}
|
||||
>
|
||||
<MenuButton className={"flex items-center gap-2 w-full"}>
|
||||
<Link className="w-full" to={url}>
|
||||
<button className={`flex items-center gap-2.5 w-full text-sm
|
||||
font-body p-2 hover:bg-hover hover:text-text-strong
|
||||
rounded-md
|
||||
${isActive ?
|
||||
"bg-card text-text-strong font-medium shadow-sm"
|
||||
:
|
||||
"text-text-primary"}`}>
|
||||
{logo}{label}
|
||||
</MenuButton>
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
173
frontend/src/components/stream/MarkerList.tsx
Normal file
173
frontend/src/components/stream/MarkerList.tsx
Normal 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;
|
||||
@@ -1,42 +1,32 @@
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import {useRef} from "react";
|
||||
import clsx from 'clsx';
|
||||
import { useRef, type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type Props = {
|
||||
videoRef: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
setSliderValue: Function;
|
||||
setMetadata: Function;
|
||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ClipRangeSlider({videoRef,
|
||||
videoMetadata,
|
||||
setSliderValue,
|
||||
setMetadata,
|
||||
className}: Props) {
|
||||
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
|
||||
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
|
||||
const previousRange = useRef<[number, number]>([0, 0]);
|
||||
|
||||
const handleRangeSliderInput = (val: [number, number]) => {
|
||||
const handleRangeInput = (val: [number, number]) => {
|
||||
if (!videoRef) return;
|
||||
|
||||
if (previousRangeSliderInput.current[0] != val[0]) {
|
||||
if (previousRange.current[0] !== val[0]) {
|
||||
videoRef.currentTime = val[0];
|
||||
setSliderValue(val[0]);
|
||||
} else if (previousRangeSliderInput.current[1] != val[1]) {
|
||||
} else if (previousRange.current[1] !== val[1]) {
|
||||
videoRef.currentTime = val[1];
|
||||
setSliderValue(val[1]);
|
||||
}
|
||||
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
startPoint: val[0],
|
||||
duration: val[1] - val[0]
|
||||
}
|
||||
))
|
||||
previousRangeSliderInput.current = val;
|
||||
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
|
||||
previousRange.current = val;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
|
||||
min={0}
|
||||
max={videoMetadata.duration}
|
||||
step={0.1}
|
||||
onInput={handleRangeSliderInput}
|
||||
className={clsx(className)}
|
||||
id={"range-slider"}
|
||||
onInput={handleRangeInput}
|
||||
className={className}
|
||||
id="range-slider"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,36 @@
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import Selector from "../Selector.tsx";
|
||||
import clsx from "clsx";
|
||||
|
||||
type prop = {
|
||||
setMetadata: Function;
|
||||
type Props = {
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ConfigBox({setMetadata, className}: prop) {
|
||||
const inputClass = "border border-hairline bg-card rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||
|
||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const vals = e.target.value.split(",");
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
width: parseInt(vals[0]),
|
||||
height: parseInt(vals[1])
|
||||
}))
|
||||
}
|
||||
const [width, height] = e.target.value.split(",").map(Number);
|
||||
setMetadata(prev => ({ ...prev, width, height }));
|
||||
};
|
||||
|
||||
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
fps: parseInt(e.target.value)
|
||||
}))
|
||||
}
|
||||
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
|
||||
};
|
||||
|
||||
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
fileSize: parseInt(e.target.value) * 1000
|
||||
}))
|
||||
}
|
||||
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-2 p-10", className)}>
|
||||
<h2 className={"text-xl font-bold"}>Export</h2>
|
||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
|
||||
|
||||
<Selector label={"Resolution"}>
|
||||
<select id="resolution"
|
||||
name="resolution"
|
||||
defaultValue="1280,720"
|
||||
onChange={updateRes}
|
||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
||||
<Selector label="Resolution">
|
||||
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||
onChange={updateRes} className={inputClass}>
|
||||
<option value="3840,2160">2160p</option>
|
||||
<option value="2560,1440">1440p</option>
|
||||
<option value="1920,1080">1080p</option>
|
||||
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
|
||||
</select>
|
||||
</Selector>
|
||||
|
||||
<Selector label={"FPS"}>
|
||||
<select id="fps"
|
||||
name="fps"
|
||||
defaultValue="30"
|
||||
onChange={updateFps}
|
||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
||||
<Selector label="FPS">
|
||||
<select id="fps" name="fps" defaultValue="30"
|
||||
onChange={updateFps} className={inputClass}>
|
||||
<option value="60">60</option>
|
||||
<option value="30">30</option>
|
||||
<option value="15">15</option>
|
||||
</select>
|
||||
</Selector>
|
||||
|
||||
<Selector label={"File Size Limit (MB)"}>
|
||||
<input type="number"
|
||||
min="1"
|
||||
defaultValue="10"
|
||||
onChange={updateFileSize}
|
||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
||||
<Selector label="File Size Limit (MB)">
|
||||
<input type="number" min="1" defaultValue="10"
|
||||
onChange={updateFileSize} className={inputClass}
|
||||
/>
|
||||
</Selector>
|
||||
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,52 @@
|
||||
import BlueButton from "../buttons/BlueButton.tsx";
|
||||
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
||||
import React from "react";
|
||||
|
||||
type props = {
|
||||
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
||||
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
|
||||
downloadable: boolean;
|
||||
progress: number
|
||||
progress: number;
|
||||
uploading: boolean;
|
||||
}
|
||||
|
||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props) => {
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
||||
return (
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<BlueButton
|
||||
onClick={dataSend}>
|
||||
Export
|
||||
</BlueButton>
|
||||
<PrimaryButton
|
||||
onClick={dataSend}
|
||||
disabled={uploading}
|
||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||
>
|
||||
{uploading && <Spinner />}
|
||||
{uploading ? "Uploading..." : "Export"}
|
||||
</PrimaryButton>
|
||||
|
||||
<div className="h-10 flex items-center">
|
||||
{ downloadable ?
|
||||
(<BlueButton
|
||||
onClick={handleDownload}>
|
||||
(<PrimaryButton className="w-full" onClick={handleDownload}>
|
||||
Download
|
||||
</BlueButton>)
|
||||
</PrimaryButton>)
|
||||
:(
|
||||
<progress
|
||||
value={progress}
|
||||
className={"bg-gray-300 rounded-lg h-1"}>
|
||||
</progress> )
|
||||
className="w-full h-1.5 rounded bg-fields">
|
||||
</progress>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import clsx from "clsx";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type MetadataBoxProps = {
|
||||
setMetadata: Function
|
||||
type Props = {
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
||||
return (
|
||||
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
|
||||
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
|
||||
|
||||
<p className={"w-full font-bold text-xl "}>Title</p>
|
||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||
<p className="w-full font-body text-text-strong font-bold text-sm">
|
||||
Title <span className="text-error">*</span>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter title"
|
||||
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
title: e.target.value
|
||||
}))}
|
||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
export default MetadataBox;
|
||||
@@ -1,52 +1,37 @@
|
||||
import {useEffect} from "react";
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type Props = {
|
||||
videoRef: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
sliderValue: number;
|
||||
setSliderValue: Function;
|
||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function PlaybackSlider({videoRef,
|
||||
videoMetadata,
|
||||
sliderValue,
|
||||
setSliderValue,
|
||||
className}: Props) {
|
||||
|
||||
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
|
||||
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!videoRef) return;
|
||||
|
||||
videoRef.currentTime = e.target.valueAsNumber;
|
||||
setSliderValue(e.target.valueAsNumber);
|
||||
}
|
||||
};
|
||||
|
||||
// update slider
|
||||
useEffect(() => {
|
||||
if (!videoRef) return;
|
||||
|
||||
const updateSlider = () => {
|
||||
setSliderValue(videoRef.currentTime);
|
||||
};
|
||||
|
||||
const updateSlider = () => setSliderValue(videoRef.currentTime);
|
||||
videoRef.addEventListener("timeupdate", updateSlider);
|
||||
|
||||
return () => {
|
||||
videoRef.removeEventListener("timeupdate", updateSlider);
|
||||
};
|
||||
return () => videoRef.removeEventListener("timeupdate", updateSlider);
|
||||
}, [videoRef]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={"range"}
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoMetadata.duration}
|
||||
value={sliderValue}
|
||||
onChange={updateVideo}
|
||||
step={0.1}
|
||||
className={clsx(className)}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import clsx from 'clsx';
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import { formatTime } from "../../utils/utils.ts";
|
||||
|
||||
|
||||
type Props = {
|
||||
video: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
|
||||
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!video) return;
|
||||
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
let Icon;
|
||||
// update icon
|
||||
if (volume == 0) {
|
||||
Icon = VolumeX;
|
||||
} else if (volume < 50) {
|
||||
Icon = Volume1;
|
||||
} else {
|
||||
Icon = Volume2;
|
||||
}
|
||||
|
||||
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!video) return;
|
||||
const newVolume = parseInt(e.target.value);
|
||||
video.volume = newVolume / 100;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
let volume = parseInt(e.target.value)
|
||||
|
||||
video.volume = volume / 100;
|
||||
setVolume(volume);
|
||||
}
|
||||
|
||||
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
|
||||
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
|
||||
return (
|
||||
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
||||
<div className={"flex gap-1"}>
|
||||
<div className="flex gap-1">
|
||||
<Icon size={24} />
|
||||
<input
|
||||
type='range'
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={updateVolume}
|
||||
value={volume}
|
||||
className={"w-20"}
|
||||
onChange={updateVolume}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={"hover:bg-gray-300 rounded-full p-1"}
|
||||
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
|
||||
<label>
|
||||
<span className="font-data text-sm text-text-secondary tabular-nums">
|
||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||
</label>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,137 @@
|
||||
import clsx from "clsx";
|
||||
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import {useEffect, useState} from "react";
|
||||
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import {config} from "../../config.ts";
|
||||
|
||||
type VideoCardProps = {
|
||||
id: number,
|
||||
title: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
className?: string
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
className?: string,
|
||||
mediaApiPath?: string,
|
||||
playerPath?: string,
|
||||
itemLabel?: string,
|
||||
}
|
||||
|
||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||
|
||||
const VideoCard = ({
|
||||
id,
|
||||
title,
|
||||
duration,
|
||||
createdAt,
|
||||
className
|
||||
}: VideoCardProps) => {
|
||||
const fallbackThumbnail = "public/default_thumbnail.png";
|
||||
const API_URL = config.apiUrl;
|
||||
|
||||
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
|
||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
|
||||
}, 1000);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
||||
|
||||
useEffect(() => {
|
||||
isThumbnailAvailable(id)
|
||||
.then((available) => {
|
||||
setThumbnailAvailable(available);
|
||||
})
|
||||
.catch(() => {
|
||||
setThumbnailAvailable(false);
|
||||
});
|
||||
}, []);
|
||||
const interval = setInterval(() => {
|
||||
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [createdAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [menuOpen]);
|
||||
|
||||
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={"/video/" + id}>
|
||||
<div className={clsx("flex flex-col", className)}>
|
||||
<div className={"relative inline-block"}>
|
||||
<Link to={`${playerPath}/${id}`}>
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
|
||||
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
|
||||
onError={() => setImgFailed(true)}
|
||||
alt="Video Thumbnail"
|
||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<p className="
|
||||
absolute
|
||||
top-2
|
||||
left-2
|
||||
bg-black bg-opacity-60
|
||||
text-white
|
||||
px-2
|
||||
py-1
|
||||
rounded
|
||||
pointer-events-none
|
||||
text-sm
|
||||
z-1
|
||||
">
|
||||
<span className="absolute top-1.5 left-1.5 bg-media-backdrop/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
||||
{formatTime(duration)}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
{(onEdit || onDelete) && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||
className="bg-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 className={"flex flex-col justify-between p-2"}>
|
||||
<p>{title == "" ? "(No Title)" : title}</p>
|
||||
<p
|
||||
className={"text-gray-600 text-sm"}
|
||||
>
|
||||
{timeAgo}
|
||||
<div className="flex flex-col p-2">
|
||||
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
|
||||
{title === "" ? "(No Title)" : title}
|
||||
</p>
|
||||
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
5
frontend/src/config.ts
Normal file
5
frontend/src/config.ts
Normal 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 ?? '',
|
||||
};
|
||||
@@ -1,96 +1,159 @@
|
||||
@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 {
|
||||
--font-display: "Satoshi", "sans-serif";
|
||||
/* ── Fonts ───────────────────────────────────────────────────────────── */
|
||||
--font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||||
--font-data: "IBM Plex Mono", monospace;
|
||||
|
||||
/* Breakpoints */
|
||||
--breakpoint-3xl: 1920px;
|
||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
||||
--color-background: #F7F4EE;
|
||||
--color-sidebar: #F2EEE6;
|
||||
--color-card: #FDFBF7;
|
||||
--color-fields: #F4F0E8;
|
||||
--color-dropzone: #F9F6EF;
|
||||
|
||||
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
|
||||
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
|
||||
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
|
||||
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
|
||||
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
|
||||
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
|
||||
not an ink colour — so `text-on-dark` always reads on top of it. */
|
||||
--color-media-backdrop: #2B2620;
|
||||
|
||||
--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 */
|
||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
||||
--color-terracotta: #A85A38;
|
||||
--color-terracotta-hover: #94492A;
|
||||
--color-terracotta-light: #C87A52;
|
||||
--color-terracotta-disabled: #C9AE9C;
|
||||
--color-olive: #6E7A50;
|
||||
|
||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
||||
--color-live: #B5432E;
|
||||
--color-error: #B5432E;
|
||||
|
||||
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
|
||||
--color-on-dark: #FBF8F2;
|
||||
--color-on-accent: #FDFBF7;
|
||||
|
||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||
--color-hairline: #F0EBE2;
|
||||
--color-hover: #E7E0D2;
|
||||
--color-selected: #F6EFE6;
|
||||
--color-ring: #E2DACB;
|
||||
--color-inactive: #DED7CA;
|
||||
--color-inactive-strong: #C4BAA9;
|
||||
}
|
||||
|
||||
#range-slider .range-slider__range{
|
||||
background: var(--color-primary);
|
||||
}
|
||||
/* Dark mode. Only token values change here: every component already styles
|
||||
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{
|
||||
background: var(--color-primary);
|
||||
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
|
||||
--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 {
|
||||
font-family: Helvetica;
|
||||
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/*a {*/
|
||||
/* font-weight: 500;*/
|
||||
/* color: #646cff;*/
|
||||
/* text-decoration: inherit;*/
|
||||
/*}*/
|
||||
/*a:hover {*/
|
||||
/* color: #535bf2;*/
|
||||
/*}*/
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/*body {*/
|
||||
/* margin: 0;*/
|
||||
/* display: flex;*/
|
||||
/* place-items: center;*/
|
||||
/* min-width: 320px;*/
|
||||
/* min-height: 100vh;*/
|
||||
/*}*/
|
||||
@utility font-heading {
|
||||
font-family: "Newsreader", serif;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/*h1 {*/
|
||||
/* font-size: 3.2em;*/
|
||||
/* line-height: 1.1;*/
|
||||
/*}*/
|
||||
/* Google Identity Services renders its sign-in button in a cross-origin iframe
|
||||
whose document is light-scheme with a transparent background. Left to inherit
|
||||
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
|
||||
white canvas behind it instead — framing the pill in a white box on every dark
|
||||
page. Pinning the iframe back to light keeps it transparent; the button's own
|
||||
colours come from the `theme` prop in Topbar.tsx. */
|
||||
iframe[src*="accounts.google.com/gsi/"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/*button {*/
|
||||
/* border-radius: 8px;*/
|
||||
/* border: 1px solid transparent;*/
|
||||
/* padding: 0.6em 1.2em;*/
|
||||
/* font-size: 1em;*/
|
||||
/* font-weight: 500;*/
|
||||
/* font-family: inherit;*/
|
||||
/* background-color: #1a1a1a;*/
|
||||
/* cursor: pointer;*/
|
||||
/* transition: border-color 0.25s;*/
|
||||
/*}*/
|
||||
/*button:hover {*/
|
||||
/* border-color: #646cff;*/
|
||||
/*}*/
|
||||
/*button:focus,*/
|
||||
/*button:focus-visible {*/
|
||||
/* outline: 4px auto -webkit-focus-ring-color;*/
|
||||
/*}*/
|
||||
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
||||
defaults; the #id beats those class selectors regardless of import order. */
|
||||
#range-slider {
|
||||
height: 8px;
|
||||
background: var(--color-fields);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/*@media (prefers-color-scheme: light) {*/
|
||||
/* :root {*/
|
||||
/* color: #213547;*/
|
||||
/* background-color: #ffffff;*/
|
||||
/* }*/
|
||||
/* a:hover {*/
|
||||
/* color: #747bff;*/
|
||||
/* }*/
|
||||
/* button {*/
|
||||
/* background-color: #f9f9f9;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
#range-slider .range-slider__range {
|
||||
background: var(--color-terracotta-light);
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--color-terracotta);
|
||||
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb:focus-visible {
|
||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
|
||||
}
|
||||
|
||||
@@ -3,38 +3,32 @@ import Sidebar from '../components/Sidebar'
|
||||
import Topbar from '../components/Topbar'
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import {useEffect, useState} from "react";
|
||||
import type {User} from "../utils/types";
|
||||
import { getUser } from "../utils/endpoints";
|
||||
import { useAuth } from "../auth/useAuth";
|
||||
import { getStreamStatus } from "../utils/api/stream";
|
||||
|
||||
const MainLayout = () => {
|
||||
const [sidebarToggled, setSidebarToggled] = useState(false);
|
||||
const [user, setUser] = useState<null | User>(null);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await getUser();
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error);
|
||||
}
|
||||
};
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
if (!user) return;
|
||||
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
|
||||
poll();
|
||||
const interval = setInterval(poll, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
|
||||
<Sidebar
|
||||
user={user}
|
||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||
<Topbar
|
||||
className={"transition-all duration-300"}
|
||||
sidebarToggled={sidebarToggled}
|
||||
setSidebarToggled={setSidebarToggled}
|
||||
user={user}
|
||||
fetchUser={fetchUser}/>
|
||||
<div className="overflow-auto">
|
||||
isStreaming={isStreaming}/>
|
||||
<div className="overflow-auto bg-background">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Playbar from "./../components/video/Playbar";
|
||||
import PlaybackSlider from "./../components/video/PlaybackSlider";
|
||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints"
|
||||
import { compress } from "../utils/api/media"
|
||||
import { getJob, downloadJob } from "../utils/api/jobs"
|
||||
import type { VideoMetadata } from "../utils/types.ts";
|
||||
import Box from "../components/Box.tsx";
|
||||
import MetadataBox from "../components/video/MetadataBox.tsx";
|
||||
|
||||
const ClipEdit = () => {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const localFile: File = location.state?.file;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const videoUrl = `/api/v1/download/input/${id}`
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [uploadedId, setUploadedId] = useState<string | null>(null);
|
||||
|
||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||
const [playbackValue, setPlaybackValue] = useState(0);
|
||||
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||
// default values
|
||||
title: "",
|
||||
description: "",
|
||||
startPoint: 0,
|
||||
@@ -29,97 +33,120 @@ const ClipEdit = () => {
|
||||
});
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [downloadable, setDownloadable] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localFile) return;
|
||||
const url = URL.createObjectURL(localFile);
|
||||
setVideoUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [localFile]);
|
||||
|
||||
const handleVideoMetadataLoaded = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setMetadata({
|
||||
title: '',
|
||||
description: '',
|
||||
startPoint: 0,
|
||||
duration: video.duration,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
fps: 30,
|
||||
fileSize: localFile?.size ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
const sendData = async () => {
|
||||
if (!id) return;
|
||||
if (!localFile || uploading) return;
|
||||
|
||||
editFile(id, outputMetadata)
|
||||
.then(() => {
|
||||
|
||||
processFile(id)
|
||||
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
|
||||
|
||||
})
|
||||
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
|
||||
|
||||
const interval = setInterval(async() => await pollProgress(id, interval), 500);
|
||||
const title = outputMetadata.title.trim();
|
||||
if (!title) {
|
||||
setError('A title is required before exporting.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollProgress = async (id: string, intervalId: number) => {
|
||||
getProgress(id)
|
||||
.then((progress) => {
|
||||
setProgress(progress.process.progress);
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
if (progress.process.complete) {
|
||||
let jobId: string;
|
||||
try {
|
||||
jobId = await compress(localFile, { ...outputMetadata, title });
|
||||
setUploadedId(jobId);
|
||||
} catch (err: unknown) {
|
||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||
};
|
||||
|
||||
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
||||
getJob(jobId)
|
||||
.then((job) => {
|
||||
setProgress(job.progress);
|
||||
|
||||
if (job.state === 'FAILED') {
|
||||
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
} else if (job.isComplete) {
|
||||
clearInterval(intervalId);
|
||||
setDownloadable(true);
|
||||
} else {
|
||||
setDownloadable(false)
|
||||
setUploading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(`Failed to fetch progress: ${err.message}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const response = await fetch(`/api/v1/download/output/${id}`);
|
||||
if (!uploadedId) return;
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Download failed');
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await downloadJob(uploadedId);
|
||||
} catch (err) {
|
||||
console.error('Download failed', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = url;
|
||||
a.download = `${id}.mp4`;
|
||||
a.download = `${uploadedId}.mp4`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
getMetadata(id)
|
||||
.then((data) => setMetadata(data ?? null))
|
||||
.catch((err) => console.error("Metadata fetch failed:", err));
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className={"grid grid-cols-[7fr_3fr]"}>
|
||||
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<source src={videoUrl} type="video/webm" />
|
||||
<source src={videoUrl} type="video/ogg" />
|
||||
Your browser does not support the video tag. Bzzzz.
|
||||
</video>
|
||||
|
||||
src={videoUrl}
|
||||
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
|
||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||
/>
|
||||
|
||||
<Box className={"w-4/5 h-full m-auto"}>
|
||||
<MetadataBox
|
||||
setMetadata={setOutputMetadata}
|
||||
/>
|
||||
<ConfigBox
|
||||
setMetadata={setOutputMetadata}
|
||||
/>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
<ConfigBox setMetadata={setOutputMetadata} />
|
||||
</Box>
|
||||
|
||||
|
||||
{metadata &&
|
||||
<Box className={"mt-4 p-5"}>
|
||||
<Playbar
|
||||
video={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
className={"w-full accent-primary"}
|
||||
className={"w-full accent-terracotta"}
|
||||
/>
|
||||
|
||||
<PlaybackSlider
|
||||
@@ -127,7 +154,7 @@ const ClipEdit = () => {
|
||||
videoMetadata={metadata}
|
||||
sliderValue={playbackValue}
|
||||
setSliderValue={setPlaybackValue}
|
||||
className={"w-full accent-primary"}
|
||||
className={"w-full accent-terracotta"}
|
||||
/>
|
||||
|
||||
<ClipRangeSlider
|
||||
@@ -135,11 +162,11 @@ const ClipEdit = () => {
|
||||
videoMetadata={metadata}
|
||||
setSliderValue={setPlaybackValue}
|
||||
setMetadata={setOutputMetadata}
|
||||
className={"w-full mt-2 bg-primary"}
|
||||
className={"w-full mt-2"}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className={"text-red-600 text-center mt-2"}>
|
||||
<div className={"text-error text-center mt-2"}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -152,10 +179,11 @@ const ClipEdit = () => {
|
||||
handleDownload={handleDownload}
|
||||
downloadable={downloadable}
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ClipEdit;
|
||||
@@ -1,97 +1,57 @@
|
||||
import {useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import Box from "../components/Box.tsx";
|
||||
|
||||
const ClipUpload = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isVideoFileSupported = (file: File): boolean => {
|
||||
const video = document.createElement("video");
|
||||
|
||||
if (file.type && video.canPlayType(file.type) !== "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
const extensionToMime: Record<string, string> = {
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogg: "video/ogg",
|
||||
mov: "video/quicktime",
|
||||
};
|
||||
|
||||
if (extension && extensionToMime[extension]) {
|
||||
return video.canPlayType(extensionToMime[extension]) !== "";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const press = (() => {
|
||||
const press = () => {
|
||||
if (!file) {
|
||||
setError("Please choose a file");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
uploadFile(file)
|
||||
.then(uuid => {
|
||||
|
||||
if (isVideoFileSupported(file)) {
|
||||
navigate(`/create/${uuid}`)
|
||||
} else {
|
||||
convertFile(uuid);
|
||||
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
|
||||
}
|
||||
|
||||
})
|
||||
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
|
||||
|
||||
});
|
||||
|
||||
const pollProgress = async (id: string, intervalId: number) => {
|
||||
getProgress(id)
|
||||
.then((progress) => {
|
||||
setProgress(progress.conversion.progress);
|
||||
|
||||
if (progress.conversion.complete) {
|
||||
clearInterval(intervalId);
|
||||
navigate(`/create/${id}`)
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(`Failed to fetch progress: ${err.message}`);
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
}
|
||||
navigate('/create/new', { state: { file } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={"flex flex-col justify-between gap-3 p-5"}>
|
||||
<div className="flex justify-center p-8">
|
||||
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||
<div
|
||||
className="cursor-pointer rounded-lg border-2
|
||||
border-dashed border-hairline bg-dropzone
|
||||
text-center text-sm text-text-secondary
|
||||
hover:bg-hover transition-colors duration-150
|
||||
min-h-50 flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<label className="cursor-pointer flex flex-col items-center justify-center gap-2 w-full h-full">
|
||||
Choose file
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
}}
|
||||
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{file && (
|
||||
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BlueButton
|
||||
onClick={press}>
|
||||
<button
|
||||
onClick={press}
|
||||
className={"hover:bg-hover p-1 rounded-md"}
|
||||
>
|
||||
Upload
|
||||
</BlueButton>
|
||||
</button>
|
||||
|
||||
<label className={"text-center text-red-500"}>{error}</label>
|
||||
<progress
|
||||
value={progress}
|
||||
className={"bg-gray-300 rounded-lg h-1"}>
|
||||
</progress>
|
||||
{error && <p className="text-sm font-body text-center">{error}</p>}
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
108
frontend/src/pages/EditClip.tsx
Normal file
108
frontend/src/pages/EditClip.tsx
Normal 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;
|
||||
108
frontend/src/pages/EditVod.tsx
Normal file
108
frontend/src/pages/EditVod.tsx
Normal 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;
|
||||
@@ -1,43 +1,78 @@
|
||||
const Home = () => {
|
||||
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Upload,
|
||||
title: "Upload & compress",
|
||||
description: "Drop in a video file, set your clip range and export settings, then compress.",
|
||||
},
|
||||
{
|
||||
icon: Scissors,
|
||||
title: "Trim",
|
||||
description: "Set start and end points with a range slider. Preview as you go.",
|
||||
},
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
title: "Export settings",
|
||||
description: "Choose resolution, frame rate, and an optional file size cap.",
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
title: "RTMP streaming",
|
||||
description: "Point OBS at your stream key and go live. Find the key in your profile.",
|
||||
},
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: "Clip library",
|
||||
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
|
||||
},
|
||||
{
|
||||
icon: Play,
|
||||
title: "Built-in player",
|
||||
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
|
||||
},
|
||||
];
|
||||
|
||||
const Home = () => {
|
||||
return (
|
||||
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center px-8 py-12 min-h-full">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="VoD System Logo"
|
||||
className={"h-44 mb-8 object-contain"}
|
||||
className="h-24 mb-6 object-contain"
|
||||
/>
|
||||
|
||||
{/* Description Container */}
|
||||
<div className={"max-w-xl text-center"}>
|
||||
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
|
||||
What is the VoD System?
|
||||
</h2>
|
||||
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
|
||||
The VoD System is a powerful clip management platform designed to streamline how you
|
||||
handle your video content. Whether you're a content creator, streamer, or educator,
|
||||
VoD System lets you:
|
||||
</p>
|
||||
|
||||
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
|
||||
<li>Upload clips effortlessly and securely.</li>
|
||||
<li>Edit and trim videos with intuitive controls.</li>
|
||||
<li>Compress files to specific file sizes.</li>
|
||||
<li>Organize your clips for quick access and sharing.</li>
|
||||
</ul>
|
||||
|
||||
<p className={"text-lg leading-relaxed text-gray-600"}>
|
||||
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
|
||||
making video clip management faster and more enjoyable than ever.
|
||||
<h1 className="text-4xl font-heading text-text-primary">
|
||||
A home for your clips.
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
Upload, trim, and export video clips. Stream live if you need to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Support Note */}
|
||||
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}>
|
||||
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
|
||||
Support for additional video formats will be added in future updates.
|
||||
{/* Feature grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
||||
{features.map(({ icon: Icon, title, description }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
||||
<Icon size={18} className="text-terracotta" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Format note */}
|
||||
<p className="text-xs text-muted">
|
||||
Supports MP4, MOV, WebM, and OGG.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
350
frontend/src/pages/MediaPlayer.tsx
Normal file
350
frontend/src/pages/MediaPlayer.tsx
Normal 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;
|
||||
@@ -1,31 +1,55 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getClips } from "../utils/endpoints";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getClips, deleteClip } from "../utils/api/clips";
|
||||
import type { Clip } from "../utils/types";
|
||||
|
||||
const MyClips = () => {
|
||||
const [clips, setClips] = useState<Clip[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getClips()
|
||||
.then((data) => setClips(data))
|
||||
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteClip(id);
|
||||
setClips(prev => prev.filter(c => c.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete clip");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex flex-row"}>
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{clips.map((clip) => (
|
||||
<VideoCard
|
||||
key={clip.id}
|
||||
id={clip.id}
|
||||
title={clip.title}
|
||||
duration={clip.duration}
|
||||
createdAt={clip.createdAt}
|
||||
className={"w-40 m-5"}
|
||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||
onDelete={() => handleDelete(clip.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error}
|
||||
{clips.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||
<p className="text-base">No clips yet.</p>
|
||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user