27 Commits

Author SHA1 Message Date
79a779ad7c fix(security): tampered token throws 500 error
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 1m24s
CI / Deploy to server (push) Successful in 7s
2026-10-17 22:46:04 +01:00
1923eab14e fix(clipping): rejects start point of zero
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 1m28s
CI / Deploy to server (push) Successful in 7s
2026-10-17 22:42:06 +01:00
cba1f9927e feat(frontend): segment clips by time range (#57)
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Successful in 41s
CI / Deploy to server (push) Successful in 7s
Resolves #41

Reviewed-on: #57
2026-10-17 22:21:59 +01:00
29a7573f5c fix(frontend): clips & vod pages briefly showing "No Clips" on load (#56)
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Successful in 42s
CI / Deploy to server (push) Successful in 7s
Resolves #42

Reviewed-on: #56
2026-10-17 22:16:19 +01:00
2818d33dd5 feat(frontend): mute dark mode colours to reduce cold feeling
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Successful in 1m48s
CI / Deploy to server (push) Successful in 16s
2026-10-17 22:05:48 +01:00
34b150b77e feat(frontend): refresh token handling (#54)
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Successful in 2m12s
CI / Deploy to server (push) Successful in 26s
Reviewed-on: #54
2026-10-16 20:57:02 +01:00
34c4072c5b test(security): user & token services tests (#53)
Some checks failed
CI / Maven unit tests (push) Successful in 15s
CI / Deploy to server (push) Has been cancelled
CI / Build and push images (push) Has been cancelled
Reviewed-on: #53
2026-10-16 20:56:22 +01:00
222b0b60f7 feat(security): add refresh token capability to API (#52)
Some checks failed
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been cancelled
CI / Deploy to server (push) Has been cancelled
## Add backend refresh tokens

Adds rotating server-side refresh tokens so the access token can be short-lived (5m) without forcing frequent re-login.

- New `POST /api/v1/users/refresh` exchanges the `refreshToken` cookie for a new refresh token and JWT
- Each login starts a token family; reusing a used token after the 5s grace period revokes the whole family
- Logout revokes the family; an hourly job deletes dead families
- Google ID token verification extracted into `GoogleVerifierService`; config moved to a validated `AuthProperties`
- Unauthenticated requests now return 401 instead of 403
- Migration `V2__refresh_tokens.sql` adds `token_families` and `refresh_tokens`

**Breaking changes**
- Cookie `token` renamed to `accessToken` (existing sessions are logged out)
- `/login` and `/refresh` no longer return the JWT in the body, only `{ accessTokenExpiresIn }`
- `jwt.secret.key` / `jwt.expiration` replaced by `app.security.*`; `JWT_SECRET_KEY` must be 32+ chars
- Frontend must call `/refresh` on 401 or before the access token expires

Reviewed-on: #52
2026-10-16 20:56:02 +01:00
f1e28780f0 chore(db): remove flyway baseline migration property
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 1m33s
CI / Deploy to server (push) Successful in 9s
2026-09-29 19:36:22 +01:00
daa6c04f76 chore(db): add flyway migration tool (#48)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 4m1s
CI / Deploy to server (push) Successful in 24s
Reviewed-on: #48
2026-09-29 19:26:45 +01:00
bd3d99e801 fix(segment-concat): out-of-bounds exception during media concat
All checks were successful
CI / Maven unit tests (push) Successful in 1m4s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
Caused by the duration exceeding 1, which caused CommandRunner to throw.
2026-09-16 20:59:57 +01:00
7dacb7a53a feat(frontend): make markers list vertical in stream page (#46)
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 44s
CI / Deploy to server (push) Successful in 9s
Resolves #38

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

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

Reviewed-on: #34
2026-08-30 17:48:59 +01:00
ec9a982fe9 docs(docstrings): add docstrings to public method interfaces (#25)
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
- Resolves #18

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

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

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

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/19
2026-08-18 21:47:47 +01:00
9c6002fe77 fix(no access page): missing mono colour scheme
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-17 23:13:52 +01:00
2f33101ec3 build(frontend): update docker image to use production env
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Successful in 27s
CI / Deploy to server (push) Successful in 8s
2026-08-17 23:06:25 +01:00
da38e21935 fix(upload): make entire dropzone clickable to open file picker
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 42s
CI / Deploy to server (push) Successful in 8s
2026-08-16 17:22:53 +01:00
3db16cd380 feat(style): update fonts & themes in frontend (#14)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 4m17s
CI / Deploy to server (push) Successful in 1m9s
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/14
2026-08-16 16:17:41 +01:00
135 changed files with 4420 additions and 3500 deletions

View File

@@ -17,5 +17,4 @@ POSTGRES_DB=vodSystem
FRONTEND_URL=
BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800
STREAM_INGEST_URL=
STREAM_RING_LENGTH_SECOND=28800

View File

@@ -4,7 +4,7 @@ on:
push:
env:
REGISTRY: gitea.vodsystem.ddns.net
REGISTRY: gitea.defaoite.dev
IMAGE_PREFIX: dylan/vod-system
jobs:

View File

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

View File

@@ -15,7 +15,7 @@ services:
restart: unless-stopped
backend:
image: gitea.vodsystem.ddns.net/dylan/vod-system-backend:latest
image: gitea.defaoite.dev/dylan/vod-system-backend:latest
container_name: vod_backend
depends_on:
- postgres
@@ -29,7 +29,7 @@ services:
restart: unless-stopped
frontend:
image: gitea.vodsystem.ddns.net/dylan/vod-system-frontend:latest
image: gitea.defaoite.dev/dylan/vod-system-frontend:latest
container_name: vod_frontend
environment:
VITE_API_URL: ${BACKEND_URL}
@@ -38,11 +38,11 @@ services:
depends_on:
- backend
ports:
- "5173:5173"
- "5173:80"
restart: unless-stopped
rtmp:
image: gitea.vodsystem.ddns.net/dylan/vod-system-rtmp:latest
image: gitea.defaoite.dev/dylan/vod-system-rtmp:latest
container_name: vod_rtmp
env_file:
- .env

View File

@@ -37,7 +37,7 @@ services:
depends_on:
- backend
ports:
- "5173:5173"
- "5173:80"
restart: unless-stopped
rtmp:

View File

@@ -1,9 +1,19 @@
FROM node:20-alpine
# --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
RUN npm run build
# --- Production stage ---
FROM nginx:1.27-alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY frontend_nginx.conf /etc/nginx/conf.d/default.conf
# Inject envs at runtime
COPY env.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

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

10
frontend/env.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
cat <<EOF > /usr/share/nginx/html/env-config.js
window._env_ = {
VITE_API_URL: "${VITE_API_URL:-}",
VITE_FRONTEND_URL: "${VITE_FRONTEND_URL:-}",
VITE_GOOGLE_CLIENT_ID: "${VITE_GOOGLE_CLIENT_ID:-}"
};
EOF

View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -9,6 +9,21 @@
<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" />
<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>

View File

@@ -7,14 +7,13 @@
"": {
"name": "frontend",
"version": "0.0.0",
"hasInstallScript": true,
"dependencies": {
"@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0"
},
"devDependencies": {
@@ -915,68 +914,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom/node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz",
"integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.9",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1043,12 +980,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iarna/toml": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
"license": "ISC"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1099,41 +1030,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@react-oauth/google": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz",
@@ -2093,9 +1989,9 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2359,9 +2255,9 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2413,81 +2309,6 @@
"node": ">=10"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz",
"integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz",
"integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.26.0",
"@typescript-eslint/visitor-keys": "8.26.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
"minimatch": "^9.0.4",
"semver": "^7.6.0",
"ts-api-utils": "^2.0.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <5.9.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
@@ -2583,9 +2404,9 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2637,23 +2458,6 @@
"node": ">=10"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.26.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz",
"integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==",
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.26.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2738,28 +2542,11 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"license": "MIT"
},
"node_modules/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
"integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
@@ -2786,18 +2573,6 @@
"concat-map": "0.0.1"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -2880,27 +2655,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2930,22 +2684,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/comment-json": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
"integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==",
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"core-util-is": "^1.0.3",
"esprima": "^4.0.1",
"has-own-prop": "^2.0.0",
"repeat-string": "^1.6.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2973,11 +2711,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -3001,22 +2744,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/debounce": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz",
"integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3037,15 +2769,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge-ts": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.4.tgz",
"integrity": "sha512-fxqo6nHGQ9zOVgI4KXqtWXJR/yCLtC7aXIVq+6jc8tHPFUxlFmuUcm2kC4vztQ+LJxQ3gER/XAWearGYQ8niGA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3246,6 +2969,7 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3272,19 +2996,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -3338,34 +3049,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3380,15 +3063,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3402,18 +3076,6 @@
"node": ">=16.0.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3452,36 +3114,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/flowbite-react": {
"version": "0.11.9",
"resolved": "https://registry.npmjs.org/flowbite-react/-/flowbite-react-0.11.9.tgz",
"integrity": "sha512-S8KwzbR2PcVv+iJDpIp62NrIjSkKIm6ZrwdssK0k4mDU8SvYSuecKstATrIxdHAIL3/FiT4VGNuCoTUSj4OcEQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "1.6.9",
"@floating-ui/react": "0.27.3",
"@iarna/toml": "2.2.5",
"@typescript-eslint/typescript-estree": "8.26.0",
"chokidar": "4.0.3",
"classnames": "2.5.1",
"comment-json": "4.2.5",
"debounce": "2.2.0",
"deepmerge-ts": "7.1.4",
"klona": "2.0.6",
"package-manager-detector": "0.2.9",
"recast": "0.23.11",
"tailwind-merge-v2": "npm:tailwind-merge@2.6.0",
"tailwind-merge-v3": "npm:tailwind-merge@3.0.1"
},
"bin": {
"flowbite-react": "dist/cli/bin.js"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"tailwindcss": "^3 || ^4"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3550,15 +3182,6 @@
"node": ">=8"
}
},
"node_modules/has-own-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz",
"integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3600,6 +3223,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3609,6 +3233,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -3617,15 +3242,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3651,9 +3267,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -3730,15 +3346,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4078,28 +3685,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -4117,12 +3702,13 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -4205,12 +3791,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-manager-detector": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.9.tgz",
"integrity": "sha512-+vYvA/Y31l8Zk8dwxHhL3JfTuHPm6tlxM2A3GeQyl7ovYnSp1+mzAxClxaOr0qO1TtPxbQxetI7v5XqKLJZk7Q==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4251,18 +3831,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -4312,26 +3880,6 @@
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -4353,6 +3901,29 @@
"react": "^19.2.8"
}
},
"node_modules/react-range-slider-input": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz",
"integrity": "sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
},
"peerDependencies": {
"react": ">=17 || >=18",
"react-dom": ">=17 || >=18"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4364,9 +3935,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4386,12 +3957,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
@@ -4401,44 +3972,6 @@
"react-dom": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/recast": {
"version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
"integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.16.1",
"esprima": "~4.0.0",
"source-map": "~0.6.1",
"tiny-invariant": "^1.3.3",
"tslib": "^2.0.1"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/repeat-string": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4449,16 +3982,6 @@
"node": ">=4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -4504,29 +4027,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4572,15 +4072,6 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4617,38 +4108,11 @@
"node": ">=8"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tailwind-merge-v2": {
"name": "tailwind-merge",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-merge-v3": {
"name": "tailwind-merge",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.1.tgz",
"integrity": "sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
@@ -4665,12 +4129,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4719,22 +4177,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
@@ -4747,7 +4194,9 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -4766,6 +4215,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4870,9 +4320,9 @@
}
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -8,16 +8,15 @@
"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",
"clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0"
},
"devDependencies": {

View File

@@ -15,6 +15,11 @@ 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() {
@@ -23,24 +28,33 @@ function App() {
}, []);
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route>
</Routes>
</Router>
<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="/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>
);
}

View File

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

View File

@@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
import {
refreshSession,
registerSessionRefreshedHandler,
registerUnauthorizedHandler,
} from "../utils/api/client.ts";
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
const REFRESH_MARGIN_MS = 60_000;
/** How soon to try again when a scheduled refresh fails because the network is down. */
const RETRY_DELAY_MS = 30_000;
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*
* It also keeps the session alive: the access token is refreshed shortly
* before it expires, rather than waiting for a request to fail. That matters
* for <video> elements, which load media straight from the API and can't
* retry on a 401 the way apiFetch does.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
const refreshDueRef = useRef<number | null>(null);
const cancelScheduledRefresh = useCallback(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
refreshDueRef.current = null;
}, []);
const endSession = useCallback(() => {
cancelScheduledRefresh();
setUser(null);
}, [cancelScheduledRefresh]);
const scheduleRefreshIn = useCallback((delayMs: number) => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshDueRef.current = Date.now() + delayMs;
refreshTimerRef.current = setTimeout(() => {
refreshSession()
.then((refreshed) => { if (!refreshed) endSession(); })
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
}, delayMs);
}, [endSession]);
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
// reports the new access token lifetime here, so the timer always tracks
// the live cookie.
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
const lifetimeMs = accessTokenExpiresIn * 1000;
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
}, [scheduleRefreshIn]);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
registerUnauthorizedHandler(endSession);
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
}, [endSession, scheduleRefreshBeforeExpiry]);
// Restore the session on page load. Refreshing first means a returning
// user whose access token expired while they were away is logged straight
// back in, and gives us the expiry to schedule the next refresh against.
useEffect(() => {
refreshSession()
.catch((error) => console.error("Failed to refresh session:", error))
.then(refresh)
.finally(() => setLoading(false));
}, [refresh]);
// Timers are throttled in background tabs and paused while the machine
// sleeps, so catch up as soon as the user comes back.
useEffect(() => {
const refreshIfDue = () => {
if (document.visibilityState !== "visible") return;
const due = refreshDueRef.current;
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
};
document.addEventListener("visibilitychange", refreshIfDue);
window.addEventListener("online", refreshIfDue);
return () => {
document.removeEventListener("visibilitychange", refreshIfDue);
window.removeEventListener("online", refreshIfDue);
};
}, [scheduleRefreshIn]);
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
const login = useCallback(async (googleCredential: string) => {
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
await refresh();
}, [refresh, scheduleRefreshBeforeExpiry]);
const logout = useCallback(async () => {
// Drop the local session even if the server call fails, so the user
// is never left looking logged in after clicking log out.
endSession();
await apiLogout();
}, [endSession]);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
{children}
</div>
);
export default Card;

View File

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

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
onClick={() => onClick(item)}
>
{item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-background border border-hairline shadow-md z-50 py-1 overflow-hidden">
{children}
</ul>
)}

View File

@@ -1,38 +0,0 @@
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
import { useAuth } from "../utils/AuthContext.tsx";
import { getUser, login } from "../utils/api/users.ts";
type Props = {
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
shape?: "rectangular" | "pill";
size?: "small" | "medium" | "large";
width?: string;
};
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
const { setUser } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => getUser().then(setUser))
.catch((error) => console.error("Login failed:", error));
};
return (
<GoogleLogin
shape={shape}
size={size}
width={width}
useOneTap={false}
onSuccess={handleLogin}
/>
);
};
export default GoogleSignIn;

View File

@@ -1,13 +0,0 @@
const IsLivePulse = () => {
return (
<span className={"relative flex h-2 w-2"}>
<span
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
></span>
</span>
);
}
export default IsLivePulse;

View File

@@ -1,57 +0,0 @@
import clsx from "clsx";
import type { JobResponse } from "../utils/types.ts";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
// queued → reading source → encoding → writing file → complete
const stageOf = (job: TrackedJob): string => {
if (job.state === 'FAILED') return "failed";
if (job.state === 'SUCCEEDED') return "complete";
if (job.state === 'READY') return "queued";
if (job.progress < 0.05) return "reading source";
if (job.progress < 0.95) return "encoding";
return "writing file";
};
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
const failed = job.state === 'FAILED';
const done = job.state === 'SUCCEEDED';
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-[width] duration-500 ease-out",
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
)}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
{stageOf(job)}
</p>
<p className="font-data text-[11px] text-faint-mono">
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
</p>
</div>
</div>
);
};
export default JobProgress;
export { isSettled };
export type { TrackedJob };

View File

@@ -1,24 +0,0 @@
import type React from "react";
import clsx from "clsx";
import { kickerClass } from "../utils/styles.ts";
type Props = {
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
kicker: string;
title: string;
/** Optional trailing action, right-aligned against the title. */
children?: React.ReactNode;
className?: string;
};
const PageHeader = ({ kicker, title, children, className }: Props) => (
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
<div className="flex flex-col gap-1">
<p className={kickerClass}>{kicker}</p>
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
</div>
{children}
</header>
);
export default PageHeader;

View File

@@ -5,11 +5,11 @@ type Props = {
label: string;
};
// Label on the left, control on the right — the shape used for export settings.
const Selector = ({ children, label }: Props) => (
<div className="flex items-center justify-between gap-4">
<label className="font-body text-[13px] text-text-body">{label}</label>
<div className="w-32 flex-shrink-0">{children}</div>
<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>
);

View File

@@ -1,9 +1,7 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import {useAuth} from "../utils/AuthContext.tsx";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import { useAuth } from "../auth/useAuth.ts";
type props = {
className?: string
@@ -11,78 +9,41 @@ type props = {
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({ className }: props) => {
const { user, setUser } = useAuth()
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
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-3xl"}>The VoD System</h2>
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2>
<nav className="flex flex-col flex-1 items-center">
<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="Upload" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div>
<Divider />
{user && (
<div className="flex flex-col gap-0.5 w-full">
<div>
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
<div 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>
</div>
)}
<div className="flex-1" />
<div className={"p-2"}>
{user ? (
<div>
<div className={"w-full"}>
{user && (
<>
<Divider />
<div className={"flex gap-2 items-center pt-1"}>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt={"Profile Picture"}
/>
<div className={"flex flex-col"}>
<p className={"font-body text-text-strong text-sm"}>
{user.name}
</p>
<p className={"text-[0.625rem] font-body text-muted wrap-break-word"}>
Signed in with Google
</p>
</div>
<button
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
) : (
<div>
<Divider />
<GoogleSignIn />
</div>
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</div>
</nav>

View File

@@ -0,0 +1,107 @@
import { Menu, X, Sun, Moon } from 'lucide-react';
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
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;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.catch((error) => {
console.error("Login failed:", error);
});
}
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
const handleLogout = () => {
logout()
.then(() => navigate("/"))
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<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 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={
<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-error font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
theme={theme === "dark" ? "filled_black" : "outline"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
</div>
)
}
export default Topbar;

View File

@@ -1,32 +0,0 @@
import React from "react";
import clsx from "clsx";
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
};
// Hover shifts one step darker within the same family — no transforms.
const variants: Record<Variant, string> = {
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
secondary: "bg-panel text-text-control hover:bg-chip-hover",
ghost: "text-muted hover:text-text-strong",
};
const Button = ({ variant = "primary", className, ...props }: Props) => (
<button
className={clsx(
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
"disabled:cursor-not-allowed",
variants[variant],
className
)}
{...props}
/>
);
export default Button;
export type { Variant };

View File

@@ -0,0 +1,15 @@
import React from "react";
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
{...props}
/>
);
};
export default MenuButton;

View File

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

View File

@@ -16,9 +16,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
<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 ?
${isActive ?
"bg-card text-text-strong font-medium shadow-sm"
:
:
"text-text-primary"}`}>
{logo}{label}
</button>
@@ -26,4 +26,4 @@ const SidebarButton = ({url, logo, label}: Props) => {
)
}
export default SidebarButton;
export default SidebarButton;

View File

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

View File

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

View File

@@ -2,49 +2,15 @@ import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
// Mirrors CompressionService.buildBitrate so the number shown is the number used:
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
const BUDGET_MULTIPLIER = 0.9;
const AUDIO_RATIO = 0.15;
const MAX_AUDIO_BITRATE = 128;
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";
type Estimate = { total: number; audio: number; video: number; bpp: number | null };
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
const { fileSize, duration, width, height, fps } = metadata;
if (!fileSize || !duration || duration <= 0) return null;
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
const video = total - audio;
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
return { total, audio, video, bpp };
};
const formatBitrate = (kbps: number): string =>
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
return { word: "very soft", tone: "text-live-red" };
};
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
@@ -55,19 +21,15 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
};
const sizeMb = Math.round(metadata.fileSize / 1000);
const estimate = estimateBitrate(metadata);
const verdict = estimate && verdictOf(estimate);
return (
<div className={clsx("flex flex-col gap-4", className)}>
<p className={kickerClass}>Export settings</p>
<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" value={`${metadata.width},${metadata.height}`}
<select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
@@ -78,8 +40,8 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="Frames per second">
<select id="fps" name="fps" value={String(metadata.fps)}
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
@@ -87,35 +49,11 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="Size limit (MB)">
<input type="number" min="1" value={sizeMb}
onChange={updateFileSize} className={monoInputClass}
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
/>
</Selector>
{/* What that budget actually buys, at this length and resolution. */}
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Estimated bitrate</p>
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
</div>
{estimate ? (
<>
<p className="font-data text-[15px] text-text-primary tabular-nums">
{formatBitrate(Math.max(0, estimate.video))}
</p>
<p className="font-body text-[12px] text-muted">
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
across {metadata.duration.toFixed(1)}s
</p>
</>
) : (
<p className="font-body text-[12px] text-muted">
Pick a trim range to see what the size limit buys you.
</p>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import React from "react";
import Button from "../buttons/Button.tsx";
import { kickerClass } from "../../utils/styles.ts";
type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>;
@@ -10,44 +9,43 @@ type props = {
uploading: boolean;
}
// queued → reading source → encoding → writing file → complete
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
if (downloadable) return "complete";
if (!uploading) return "waiting";
if (progress < 0.05) return "reading source";
if (progress < 0.95) return "encoding";
return "writing file";
};
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
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-4">
<p className={kickerClass}>Export</p>
<div className={"flex flex-col gap-3"}>
<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>
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
{uploading ? "Compressing…" : "Compress and save"}
</Button>
<div className="flex flex-col gap-1.5">
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className="font-data text-[11px] text-muted">{stageOf(progress, uploading, downloadable)}</p>
<p className="font-data text-[11px] text-faint-mono">{downloadable ? "done" : `${pct}%`}</p>
</div>
<div className="h-10 flex items-center">
{ downloadable ?
(<PrimaryButton className="w-full" onClick={handleDownload}>
Download
</PrimaryButton>)
:(
<progress
value={progress}
className="w-full h-1.5 rounded bg-fields">
</progress>
)
}
</div>
{downloadable && (
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
Download the file
</Button>
)}
</div>
)
}

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -9,14 +8,16 @@ type Props = {
};
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col gap-2", className)}>
<label className={labelClass} htmlFor="clip-title">Title</label>
<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
id="clip-title"
type="text"
placeholder="Cat walks across the patch bay"
required
placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className={inputClass}
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>
);

View File

@@ -0,0 +1,37 @@
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: Dispatch<SetStateAction<number>>;
className?: string;
};
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);
};
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={className}
/>
);
}

View File

@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}, [video]);
return (
<div className={clsx("flex justify-between items-center mb-3", className)}>
<div className="flex items-center gap-2 text-text-control">
<Icon size={20} />
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className="flex gap-1">
<Icon size={24} />
<input
type="range"
min={0}
max={100}
value={volume}
onChange={updateVolume}
className="w-20 accent-accent"
className="w-20"
/>
</div>
<button
onClick={togglePlay}
className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
>
{isPlaying ? <Pause size={22} /> : <Play size={22} />}
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<span className="font-data text-[13px] text-text-secondary tabular-nums">
<span className="font-data text-sm text-text-secondary tabular-nums">
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span>
</div>

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import { groupByTimeRange } from "../../utils/utils";
type props<T> = {
items: T[];
getDate: (item: T) => string;
renderItem: (item: T) => ReactNode;
}
const TimeGroupedGrid = <T,>({ items, getDate, renderItem }: props<T>) => {
return (
<div className="flex flex-col gap-8">
{groupByTimeRange(items, getDate).map((group) => (
<section key={group.label}>
<h2 className="text-xl font-heading text-text-primary mb-3">{group.label}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map(renderItem)}
</div>
</section>
))}
</div>
);
}
export default TimeGroupedGrid;

View File

@@ -1,283 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Range = [number, number];
type Props = {
/** Full length of the source video, in seconds. */
duration: number;
/** The trim window, in seconds. */
range: Range;
onRangeChange: (range: Range) => void;
/** Playhead position, in seconds. */
currentTime: number;
onSeek: (seconds: number) => void;
className?: string;
};
/** Never zoom in past this, or the handles have nothing left to grab. */
const MIN_VIEW_SECONDS = 0.5;
/** The shortest trim the handles will allow between them. */
const MIN_GAP = 0.1;
const ZOOM_STEP = 1.6;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
/** A readable tick interval that yields roughly 6 labels across the view. */
const tickInterval = (span: number): number => {
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
return steps.find((step) => span / step <= 6) ?? 3600;
};
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
const [view, setView] = useState<Range>([0, duration]);
// Which thing the pointer is currently dragging, if any.
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const overviewRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef(0);
// A new source resets the view to the whole thing.
useEffect(() => setView([0, duration]), [duration]);
const [viewStart, viewEnd] = view;
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
const zoomFactor = duration / viewSpan;
/** Seconds → percentage across the zoomed track. */
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
/** Pointer x → seconds, within the zoomed track. */
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width === 0) return span[0];
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return span[0] + ratio * (span[1] - span[0]);
};
/** Re-centre the view on `focus`, keeping it inside the source. */
const applyZoom = useCallback((nextSpan: number, focus: number) => {
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
let start = focus - span * ratio;
start = clamp(start, 0, Math.max(0, duration - span));
setView([start, start + span]);
}, [duration, view]);
const zoomBy = (factor: number) => {
const focus = clamp(currentTime, viewStart, viewEnd);
applyZoom(viewSpan / factor, focus);
};
/** Zoom the view down to the trim range, with a little air on each side. */
const zoomToRange = () => {
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
const padded = Math.min(duration, span * 1.25);
const centre = (range[0] + range[1]) / 2;
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
setView([start, start + padded]);
};
// Wheel over the track zooms around the pointer rather than scrolling the page.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
event.preventDefault();
const focus = toSeconds(event.clientX, track, view);
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
};
track.addEventListener("wheel", onWheel, { passive: false });
return () => track.removeEventListener("wheel", onWheel);
}, [applyZoom, view, viewSpan]);
// The move handler reads through refs, so it always sees the current view and
// range without having to be torn down and rebuilt on every frame of a drag.
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
latest.current = { view, range, onRangeChange, onSeek, duration };
// Listeners are attached on mousedown rather than in an effect: a fast drag
// would otherwise lose its first frames waiting for a re-render.
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setDragging(kind);
const onMove = (move: MouseEvent) => {
const state = latest.current;
const span = state.view[1] - state.view[0];
if (kind === "window") {
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
setView([start, start + span]);
return;
}
const at = toSeconds(move.clientX, trackRef.current, state.view);
if (kind === "playhead") {
state.onSeek(at);
} else if (kind === "in") {
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
state.onSeek(at);
} else {
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
state.onSeek(at);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDragging(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
if (duration <= 0) return null;
const interval = tickInterval(viewSpan);
const firstTick = Math.ceil(viewStart / interval) * interval;
const ticks: number[] = [];
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
const windowLeft = (viewStart / duration) * 100;
const windowWidth = (viewSpan / duration) * 100;
return (
<div className={clsx("flex flex-col gap-3 select-none", className)}>
{/* Overview — the whole source, with the zoom window drawn on top. */}
<div
ref={overviewRef}
onMouseDown={(event) => {
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
// Grabbing inside the window drags it; clicking outside jumps it here.
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
setView([start, start + viewSpan]);
beginDrag("window", event);
}}
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
>
<div
className="absolute inset-y-0 bg-olive-light/30"
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 w-px bg-accent"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
/>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={viewSpan >= duration}
title="Zoom out"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
−
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={viewSpan <= MIN_VIEW_SECONDS}
title="Zoom in"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
+
</button>
<span className="font-data text-[11px] text-faint-mono tabular-nums">
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
{" · "}
{formatTime(viewStart)} – {formatTime(viewEnd)}
</span>
<div className="flex-1" />
<button
onClick={zoomToRange}
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
>
Fit to trim
</button>
<button
onClick={() => setView([0, duration])}
disabled={viewSpan >= duration}
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
>
Whole clip
</button>
</div>
{/* Zoomed track */}
<div
ref={trackRef}
onMouseDown={(event) => {
onSeek(toSeconds(event.clientX, trackRef.current, view));
beginDrag("playhead", event);
}}
className="relative h-14 rounded-chip bg-fields cursor-pointer"
>
{ticks.map((at) => (
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
<div className="absolute inset-y-0 w-px bg-inactive/70" />
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
{formatTime(at)}
</span>
</div>
))}
{/* Trim band */}
<div
className="absolute inset-y-0 bg-olive-light/35"
style={{
left: `${clamp(toPct(range[0]), 0, 100)}%`,
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
}}
/>
{/* Playhead */}
<div
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
/>
{/* In / out handles — only grabbable while inside the view */}
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
const pct = toPct(at);
if (pct < -2 || pct > 102) return null;
return (
<div
key={edge}
onMouseDown={(event) => beginDrag(edge, event)}
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
className={clsx(
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
dragging === edge && "z-10"
)}
style={{ left: `${pct}%` }}
>
<div className="h-full w-[3px] bg-olive rounded-full" />
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
</div>
);
})}
</div>
</div>
);
};
export default TrimTimeline;
export type { Range };

View File

@@ -1,20 +1,15 @@
import clsx from "clsx";
import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = {
id: number,
title: string,
duration: number,
createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void,
onDelete?: () => void,
className?: string,
@@ -23,42 +18,23 @@ type VideoCardProps = {
itemLabel?: string,
}
const API_URL = import.meta.env.VITE_API_URL;
const fallbackThumbnail = "public/default_thumbnail.png";
const API_URL = config.apiUrl;
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
const placeholders: Record<string, React.CSSProperties> = {
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
};
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
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 [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`;
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
const isVod = itemLabel === 'vod';
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
const meta = [
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
fileSize ? formatFileSize(fileSize) : null,
].filter(Boolean).join(" · ");
// The technical detail, in mono, for when it matters.
const facts = [
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
isVod && fps ? `${Math.round(fps)} fps` : null,
].filter(Boolean);
useEffect(() => {
isThumbnailAvailable(thumbnailUrl)
.then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false));
}, [thumbnailUrl]);
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => {
if (!menuOpen) return;
@@ -80,28 +56,16 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
return (
<Link to={`${playerPath}/${id}`}>
<div className={clsx(
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
className
)}>
<div
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
>
{thumbnailAvailable && (
<img
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
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"
/>
<span className="absolute top-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{slug}
</span>
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
<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)}
</span>
@@ -113,18 +77,18 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
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-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
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>
@@ -132,7 +96,7 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
>
<Trash2 size={13} /> Delete
</button>
@@ -140,17 +104,17 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
<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 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
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 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
>
Cancel
</button>
@@ -163,28 +127,11 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
)}
</div>
<div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
<p className={clsx(
"line-clamp-2",
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
<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="font-body text-[12.5px] text-muted"
title={`${dateToTimeAgo(stringToDate(createdAt))} — ${formatLocalDate(createdAt)}`}
>
{meta}
</p>
{facts.length > 0 && (
<p className="font-data text-[10.5px] text-faint-mono truncate">
{facts.join(" · ")}
</p>
)}
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
</div>
</div>
</Link>

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

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

View File

@@ -1,5 +1,9 @@
@import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
/* 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 {
/* ── Fonts ───────────────────────────────────────────────────────────── */
@@ -11,80 +15,102 @@
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-panel: #F1ECE3;
--color-dropzone: #F9F6EF;
--color-armed: #F6EFE6;
--color-live-card: #F0EBE1;
--color-player: #2B2620;
--color-player-stripe: #3A342C;
/* 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;
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
--color-text-body: #554D44;
--color-text-control: #4A423A;
--color-text-secondary: #6E6559;
--color-text-table: #7A7166;
--color-muted: #9A9084;
--color-faint-mono: #A79C8E;
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-accent: #A85A38;
--color-accent-hover: #94492A;
--color-accent-light: #C87A52;
--color-accent-disabled: #C9AE9C;
--color-terracotta: #A85A38;
--color-terracotta-hover: #94492A;
--color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
--color-olive-hover: #5D6843;
--color-olive-light: #8A9668;
--color-live-red: #B5432E;
/* ── 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-chip: #EAE3D7;
--color-chip-hover: #DAD1C0;
--color-ring-dashed: #E2DACB;
--color-selected: #F6EFE6;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
}
/* ── Elevation — soft shadows instead of borders ─────────────────────── */
--shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35);
--shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45);
--shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3);
--shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5);
--shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55);
--shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55);
/* 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.
--inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08);
--inset-shadow-armed: inset 0 0 0 1.5px #A85A38;
--inset-shadow-focus: inset 0 0 0 1.5px #C87A52;
--inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB;
Soft charcoal rather than pure black, with only a trace of warmth so it
still sits with the paper tones of the light theme. Ink is a warm off-white
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
light theme: sidebar recessed, cards raised, fields inset into the card. */
:root[data-theme="dark"] {
color-scheme: dark;
/* ── Shape ───────────────────────────────────────────────────────────── */
--radius-hero: 16px;
--radius-panel: 15px;
--radius-tile: 13px;
--radius-thumb: 12px;
--radius-field: 10px;
--radius-btn: 9px;
--radius-chip: 8px;
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #161615;
--color-sidebar: #111111;
--color-card: #1F1F1E;
--color-fields: #1A1A19;
--color-dropzone: #1C1C1B;
/* ── Motion — the live dot, plus the player's one-shot action badge ──── */
--animate-live-pulse: livePulse 2.2s ease-in-out infinite;
--animate-flash-badge: flashBadge 500ms ease-out forwards;
--color-media-backdrop: #0B0B0B;
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .45; transform: scale(1.35); }
}
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F5F4EF;
--color-text-strong: #E8E6E0;
--color-text-body: #C9C7C0;
--color-text-secondary: #A2A099;
--color-muted: #87857F;
--color-faint-mono: #6C6B66;
@keyframes flashBadge {
0% { opacity: 0; transform: scale(.75); }
25% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.4); }
}
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */
--color-terracotta: #D97757;
--color-terracotta-hover: #E28B6E;
--color-terracotta-light: #BC6A4E;
--color-terracotta-disabled: #46372F;
--color-olive: #A7AF84;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0644E;
--color-error: #E0644E;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #1A1410;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #282827;
--color-hover: #2A2A29;
--color-selected: #2A2522;
--color-ring: #3B3B3A;
--color-inactive: #333332;
--color-inactive-strong: #5A5955;
}
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
--tw-shadow-color: rgb(0 0 0 / 0.45);
}
:root {
@@ -95,6 +121,7 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: light;
}
body {
@@ -106,5 +133,38 @@ body {
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
color: #2B2620;
color: var(--color-text-primary);
}
/* Google Identity Services renders its sign-in button in a cross-origin iframe
whose document is light-scheme with a transparent background. Left to inherit
our `color-scheme: dark`, Chrome cannot honour dark there and paints an opaque
white canvas behind it instead — framing the pill in a white box on every dark
page. Pinning the iframe back to light keeps it transparent; the button's own
colours come from the `theme` prop in Topbar.tsx. */
iframe[src*="accounts.google.com/gsi/"] {
color-scheme: light;
}
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {
height: 8px;
background: var(--color-fields);
border-radius: 8px;
}
#range-slider .range-slider__range {
background: var(--color-terracotta-light);
}
#range-slider .range-slider__thumb {
width: 18px;
height: 18px;
background: var(--color-terracotta);
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
}
#range-slider .range-slider__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
}

View File

@@ -1,19 +1,37 @@
// layout/MainLayout.jsx
import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import { useState } from "react";
import {useEffect, useState} from "react";
import { useAuth } from "../auth/useAuth";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => {
const [sidebarToggled] = useState(false);
const [sidebarToggled, setSidebarToggled] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
useEffect(() => {
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-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<div className="row-span-2 overflow-y-auto">
<Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />
</div>
</div>
);
};
export default MainLayout;
export default MainLayout;

View File

@@ -1,13 +1,10 @@
import './index.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {AuthProvider} from "./utils/AuthContext.tsx";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
<App />
</StrictMode>,
)

View File

@@ -1,19 +1,15 @@
import { useLocation } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar";
import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
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 { compress } from "../utils/api/media"
import { getJob } from "../utils/api/jobs"
import { getJob, downloadJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts";
import Card from "../components/Card.tsx";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { formatTime, formatTimecode } from "../utils/utils.ts";
import { kickerClass } from "../utils/styles.ts";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => {
const location = useLocation();
@@ -25,7 +21,6 @@ const ClipEdit = () => {
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0);
const [range, setRange] = useState<Range>([0, 5]);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
title: "",
description: "",
@@ -61,39 +56,23 @@ const ClipEdit = () => {
fps: 30,
fileSize: localFile?.size ?? 0,
});
// Start with the whole thing trimmed in — narrowing is the common move.
setRange([0, video.duration]);
setOutputMetadata(prev => ({ ...prev, startPoint: 0, duration: video.duration }));
};
// Keep the playhead in step with the video while it plays.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => setPlaybackValue(video.currentTime);
video.addEventListener("timeupdate", onTime);
return () => video.removeEventListener("timeupdate", onTime);
}, [videoUrl]);
const handleRangeChange = useCallback((next: Range) => {
setRange(next);
setOutputMetadata(prev => ({ ...prev, startPoint: next[0], duration: next[1] - next[0] }));
}, []);
const handleSeek = useCallback((seconds: number) => {
setPlaybackValue(seconds);
if (videoRef.current) videoRef.current.currentTime = seconds;
}, []);
const sendData = async () => {
if (!localFile || uploading) return;
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
setUploading(true);
setError(null);
let jobId: string;
try {
jobId = await compress(localFile, outputMetadata);
jobId = await compress(localFile, { ...outputMetadata, title });
setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -129,14 +108,14 @@ const ClipEdit = () => {
const handleDownload = async () => {
if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
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');
@@ -149,78 +128,60 @@ const ClipEdit = () => {
};
return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Upload" title="Trim and compress" />
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
<div className="flex flex-col gap-[26px]">
{/* Player — the video and its transport read as one object. */}
<Card className="overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="w-full aspect-video bg-player object-contain"
onLoadedMetadata={handleVideoMetadataLoaded}
/>
{metadata &&
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className="w-full px-5 pt-3.5"
/>
}
</Card>
<Box className={"w-4/5 h-full m-auto"}>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Box>
{metadata &&
<Card className="p-6">
<div className="flex items-baseline justify-between gap-4 pb-4">
<p className={kickerClass}>Trim</p>
<p className="font-data text-[12px] text-faint-mono tabular-nums">
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
{formatTime(range[1] - range[0])} long
</p>
</div>
{metadata &&
<Box className={"mt-4 p-5"}>
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className={"w-full accent-terracotta"}
/>
<TrimTimeline
duration={metadata.duration}
range={range}
onRangeChange={handleRangeChange}
currentTime={playbackValue}
onSeek={handleSeek}
/>
<PlaybackSlider
videoRef={videoRef.current}
videoMetadata={metadata}
sliderValue={playbackValue}
setSliderValue={setPlaybackValue}
className={"w-full accent-terracotta"}
/>
<p className="pt-3 font-body text-[12px] text-muted">
Scroll over the timeline to zoom, drag the outlined window above to pan.
</p>
<ClipRangeSlider
videoRef={videoRef.current}
videoMetadata={metadata}
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2"}
/>
{error && (
<p className="font-body text-[13px] text-live-red pt-3">{error}</p>
)}
</Card>
}
</div>
{error && (
<div className={"text-error text-center mt-2"}>
{error}
</div>
)}
</Box>
}
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
<Card className="p-6 flex flex-col gap-4">
<p className={kickerClass}>Details</p>
<MetadataBox setMetadata={setOutputMetadata} />
</Card>
<Card className="p-6">
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
</Card>
<div className="bg-panel rounded-panel shadow-panel p-6">
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</div>
</aside>
</div>
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</Box>
</div>
);
};

View File

@@ -1,89 +1,56 @@
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import clsx from "clsx";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { kickerClass } from "../utils/styles.ts";
const formatSize = (bytes: number): string => {
const mb = bytes / 1_000_000;
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`;
};
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import Box from "../components/Box.tsx";
const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<null | string>(null);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const choose = (selected: File | null) => {
setFile(selected);
setError(null);
};
const press = () => {
if (!file) {
setError("Pick a file first.");
setError("Please choose a file");
return;
}
navigate('/create/new', { state: { file } });
};
return (
<div className="mx-auto max-w-180 px-13 pt-10 pb-18">
<PageHeader kicker="Upload" title="Bring in a file" />
<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="hidden"
/>
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<label
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
choose(e.dataTransfer.files?.[0] ?? null);
}}
className={clsx(
"flex aspect-16/7 cursor-pointer flex-col items-center justify-center gap-2 rounded-hero px-8 text-center transition-colors duration-150",
dragging ? "bg-armed inset-shadow-armed" : "bg-dropzone inset-shadow-dashed hover:bg-fields"
)}
>
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => choose(e.target.files?.[0] ?? null)}
/>
<button
onClick={press}
className={"hover:bg-hover p-1 rounded-md"}
>
Upload
</button>
{file ? (
<>
<p className="font-heading text-[25px]">{file.name}</p>
<p className="font-data text-[12px] text-faint-mono">{formatSize(file.size)}</p>
<p className="font-body text-[13px] text-muted pt-1">Click to pick a different one.</p>
</>
) : (
<>
<p className={kickerClass}>Video file</p>
<p className="font-heading text-[25px]">Drop a file here</p>
<p className="font-body text-[14.5px] text-text-body max-w-[42ch] text-pretty">
Or click anywhere in this box to browse. You'll trim and compress it on the next screen.
</p>
</>
)}
</label>
<div className="flex items-center gap-4 pt-6">
<Button onClick={press} disabled={!file} className="px-6 py-3">
{file ? "Trim and compress" : "Waiting for a file"}
</Button>
{file && (
<Button variant="ghost" onClick={() => { choose(null); if (inputRef.current) inputRef.current.value = ""; }}>
Clear
</Button>
)}
</div>
{error && <p className="font-body text-[13px] text-live-red pt-3">{error}</p>}
{error && <p className="text-sm font-body text-center">{error}</p>}
</Box>
</div>
)
};

View File

@@ -1,12 +1,11 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/api/clips.ts";
import Card from "../components/Card.tsx";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.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();
@@ -48,51 +47,59 @@ const EditClip = () => {
};
return (
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
<ArrowLeft size={15} />
<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>
<PageHeader kicker="Clip" title="Edit details" />
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
{loading ? (
<p className="font-data text-[13px] text-faint-mono">loading…</p>
<div className="text-muted text-sm">Loading...</div>
) : (
<Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor="clip-title">Title</label>
<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
id="clip-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Cat walks across the patch bay"
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor="clip-description">Description</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Description</label>
<textarea
id="clip-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's worth remembering about it"
placeholder="Enter description"
rows={4}
className={clsx(inputClass, "resize-none leading-[1.65]")}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
{error && <p className="text-sm text-error">{error}</p>}
<div className="flex items-center gap-4">
<Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
<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>
</Card>
</Box>
)}
</div>
);

View File

@@ -1,12 +1,11 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { ArrowLeft, Check } from "lucide-react";
import { getVodById, patchVod } from "../utils/api/vods.ts";
import Card from "../components/Card.tsx";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.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();
@@ -48,51 +47,59 @@ const EditVod = () => {
};
return (
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
<ArrowLeft size={15} />
<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>
<PageHeader kicker="VoD" title="Edit details" />
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
{loading ? (
<p className="font-data text-[13px] text-faint-mono">loading…</p>
<div className="text-muted text-sm">Loading...</div>
) : (
<Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor="vod-title">Title</label>
<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
id="vod-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Tuesday night, patch bay walkthrough"
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor="vod-description">Description</label>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Description</label>
<textarea
id="vod-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's worth remembering about it"
placeholder="Enter description"
rows={4}
className={clsx(inputClass, "resize-none leading-[1.65]")}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
{error && <p className="text-sm text-error">{error}</p>}
<div className="flex items-center gap-4">
<Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
<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>
</Card>
</Box>
)}
</div>
);

View File

@@ -1,311 +1,78 @@
import {useAuth} from "../utils/AuthContext.tsx";
import Landing from "./Landing.tsx";
import {Link, useNavigate} from "react-router-dom";
import Card from "../components/Card.tsx";
import {useEffect, useState} from "react";
import IsLivePulse from "../components/IsLivePulse.tsx";
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
import {getCurrentStream} from "../utils/api/stream.ts";
import VideoCard from "../components/video/VideoCard.tsx";
import {createMarker, getMarkers} from "../utils/api/markers.ts";
import clsx from "clsx";
import {getVods} from "../utils/api/vods.ts";
import {getClips} from "../utils/api/clips.ts";
import Button from "../components/buttons/Button.tsx";
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 17) return "afternoon";
if (hour >= 17 && hour < 22) return "evening";
return "night";
}
function getGreetingSubtext(date: Date = new Date()): string {
const timeOfDay = getTimeOfDay(date);
const messages: Record<TimeOfDay, string[]> = {
morning: [
"Good morning.",
"Coffee first, then clips.",
"Here's what's waiting for you.",
],
afternoon: [
"Good afternoon.",
"A quiet moment for your clips.",
"Catching up?",
],
evening: [
"Good evening.",
"Settle in.",
"Time to unwind.",
],
night: [
"Still up?",
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
function getTimeFromISOString(date: string | null): string {
if (date == null) {
return "--:--";
}
const d = new Date(date);
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(d);
}
type SectionHeaderProps = { title: string; to: string; count: number };
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
<h2 className={"font-heading text-2xl"}>{title}</h2>
{count > 0 && (
<Link
to={to}
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
>
All {count} <span aria-hidden={true}>→</span>
</Link>
)}
</div>
);
const features = [
{
icon: Upload,
title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
},
{
icon: Scissors,
title: "Trim",
description: "Set start and end points with a range slider. Preview as you go.",
},
{
icon: SlidersHorizontal,
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.",
},
{
icon: Radio,
title: "RTMP streaming",
description: "Point OBS at your stream key and go live. Find the key in your profile.",
},
{
icon: LayoutGrid,
title: "Clip library",
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
},
{
icon: Play,
title: "Built-in player",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
},
];
const Home = () => {
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
const { user } = useAuth();
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
const [ elapsed, setElapsed ] = useState('00:00:00');
const [ markers, setMarkers ] = useState<Marker[]>([])
const [ clips, setClips ] = useState<Clip[]>([]);
const [ vods, setVods ] = useState<Vod[]>([]);
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
const [ markerBusy, setMarkerBusy ] = useState(false);
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
const isStreaming = streamStatus?.isStreaming ?? false;
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
const navigate = useNavigate();
// Fetch Markers & Stream information
useEffect(() => {
getCurrentStream()
.then(data => {
setStreamStatus(data);
});
getMarkers()
.then(data => {
setMarkers(data);
})
// Get Top 4 Clips
getClips()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4);
setClips(topSix);
setCounts(prev => ({ ...prev, clips: data.length }));
})
// Get top 3 vods
getVods()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
setVods(topSix);
setCounts(prev => ({ ...prev, vods: data.length }));
})
}, [user]);
const handleDropMarker = async () => {
if (markerBusy) return;
setMarkerBusy(true);
try {
await createMarker(`Marker at ${elapsed}`);
setMarkers(await getMarkers());
setMarkerNote(`dropped at ${elapsed}`);
} catch (err) {
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
} finally {
setMarkerBusy(false);
}
};
useEffect(() => {
if (streamStatus?.startTime == null) {
setElapsed('00:00:00');
return;
}
const startMs = new Date(streamStatus?.startTime).getTime();
const tick = () => {
const diffSec = Math.floor((Date.now() - startMs) / 1000);
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
const ss = String(diffSec % 60).padStart(2, '0');
setElapsed(`${hh}:${mm}:${ss}`);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [streamStatus?.startTime]);
if (user == null) {
return (<Landing />)
}
return (
<div className={"w-10/12 m-auto pt-10"}>
<div className={"flex flex-row justify-between items-start pb-8"}>
<div>
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
</div>
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
Open Stream Panel
</Link>
<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-24 mb-6 object-contain"
/>
<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>
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
<div className={"flex flex-row items-center gap-2"}>
{isStreaming
? <IsLivePulse />
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
<p className={clsx(
"text-xs font-data tracking-[0.12em]",
isStreaming ? "text-live-red" : "text-faint-mono"
)}>
{isStreaming ? "LIVE NOW" : "OFF AIR"}
</p>
</div>
<div className={"p-1"}></div>
{isStreaming ? (
<>
<h2 className={"font-heading text-3xl"}>
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
recording since {getTimeFromISOString(streamStatus?.startTime ?? null)}.</h2>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{elapsed}</h2>
<p className={"text-xs text-muted"}>elapsed</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{markers.length}</h2>
<p className={"text-xs text-muted"}>markers dropped</p>
</div>
{/* 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 className={"mt-4 flex items-center gap-4"}>
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
<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>
</>
) : (
<>
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
Point OBS at the ingest URL with your stream key and this block turns over to
elapsed time and markers.
</p>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
<p className={"text-xs text-muted"}>vods saved</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
<p className={"text-xs text-muted"}>clips cut</p>
</div>
</div>
<div className={"mt-4"}>
<Link to={"/stream"}>
<Button variant={"secondary"}>Open the stream panel</Button>
</Link>
</div>
</>
)}
</Card>
<div className={"flex flex-col"}>
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
<div className={"grid grid-cols-3 gap-5.5"}>
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods"
playerPath="/video"
itemLabel="vod"
onEdit={() => navigate(`/video/${vod.id}/edit`)}
/>
))}
</div>
</div>
<div className={"flex flex-col mt-9"}>
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
<div className={"grid grid-cols-4 gap-4.5"}>
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
mediaApiPath="/api/v1/clips"
playerPath="/video"
itemLabel="clip"
onEdit={() => navigate(`/video/${clip.id}/edit`)}
/>
))}
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG.
</p>
</div>
);
};

View File

@@ -1,108 +0,0 @@
import Card from "../components/Card.tsx";
import GoogleSignIn from "../components/GoogleSignIn.tsx";
import { kickerClass } from "../utils/styles.ts";
const steps = [
{
number: "01",
title: "Point OBS at it",
body: "One ingest URL and one stream key. Everything you send lands on disk as it arrives — no upload step, no waiting at the end.",
},
{
number: "02",
title: "Mark it as it happens",
body: "Drop a marker the moment something is worth keeping. Each one remembers its timecode so you don't have to scrub for it later.",
},
{
number: "03",
title: "Cut it up afterwards",
body: "Tick two markers to pull the stretch between them out as a VoD, or take the last minute as a clip. Cutting runs in the background while you keep streaming.",
},
];
/** Stand-in for the marker list on the stream panel — the shape of the thing, in miniature. */
const previewMarkers = [
{ at: "00:10:12", message: "Patch bay walkthrough begins", selected: false },
{ at: "00:49:56", message: "Filter finally tracks", selected: true },
{ at: "00:50:37", message: "…and it stops tracking", selected: true },
{ at: "01:05:01", message: "Cat on the desk", selected: false },
];
const Landing = () => (
<div className="mx-auto max-w-[1180px] px-13 pt-16 pb-18">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] gap-12 items-start pb-16">
<div className="flex flex-col gap-5">
<p className={kickerClass}>Personal stream archive</p>
<h1 className="font-heading text-5xl tracking-[-0.015em] leading-[1.1] max-w-[16ch]">
Keep the good bits of everything you stream.
</h1>
<p className="font-body text-[16px] leading-[1.65] text-text-body max-w-[58ch] text-pretty">
The VoD System sits between your encoder and your hard drive. It records
every session in full, lets you mark the moments worth keeping while
they're happening, and turns those marks into VoDs and clips afterwards.
</p>
<div className="flex flex-col gap-3 pt-2">
<GoogleSignIn shape="rectangular" size="large" width="260" />
<p className="font-body text-[12.5px] text-muted">
Sign in with Google to reach your library.
</p>
</div>
</div>
{/* A quiet look at what's behind the login. */}
<Card className="p-6 flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Live markers</p>
<p className="font-data text-[12px] text-faint-mono">t = 01:33:35</p>
</div>
<div className="flex flex-col gap-2">
{previewMarkers.map((marker) => (
<div
key={marker.at}
className={`flex items-center gap-3 rounded-tile px-3 py-2.5 ${
marker.selected ? "bg-armed inset-shadow-armed" : "bg-fields"
}`}
>
<span className={`h-[14px] w-[14px] flex-shrink-0 rounded-[4px] ${
marker.selected ? "bg-accent" : "bg-inactive"
}`} />
<span className="font-data text-[12px] text-accent flex-shrink-0">{marker.at}</span>
<span className="font-body text-[12.5px] text-text-strong truncate">{marker.message}</span>
</div>
))}
</div>
<p className="font-body text-[12.5px] text-muted pt-1">
Two marks selected — that's a 41-second VoD, one click away.
</p>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-[22px]">
{steps.map((step) => (
<Card key={step.number} className="p-6 flex flex-col gap-2.5">
<p className="font-data text-[13px] text-accent">{step.number}</p>
<h2 className="font-heading text-[22px] leading-snug">{step.title}</h2>
<p className="font-body text-[14px] leading-[1.65] text-text-body text-pretty">
{step.body}
</p>
</Card>
))}
</div>
<div className="bg-panel rounded-panel p-6 mt-[26px] flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
<p className="font-body text-[14px] text-text-body">
Built for one person and their own machine. No tiers, no transcoding queue you share with strangers.
</p>
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">
rtmp in · mp4 out
</p>
</div>
</div>
);
export default Landing;

View File

@@ -1,21 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
import clsx from "clsx";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import { AuthError } from "../utils/api/client.ts";
import Card from "../components/Card.tsx";
import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
import { kickerClass, quietLinkClass } from "../utils/styles.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,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
};
type MediaPlayerProps = {
@@ -60,15 +53,12 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const [hoverTime, setHoverTime] = useState<number | null>(null);
/** Transient badge that flashes in the middle of the frame on play/pause/seek. */
const [flash, setFlash] = useState<{ id: number; kind: "play" | "pause" | "back" | "forward" } | null>(null);
const showFlash = useCallback((kind: "play" | "pause" | "back" | "forward") => {
setFlash({ id: Date.now(), kind });
}, []);
// ── 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; }
@@ -78,7 +68,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`);
});
}, [id, noun, fetchDetails]);
useEffect(() => {
@@ -100,6 +89,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
@@ -129,13 +119,11 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showFlash('back');
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showFlash('forward');
showControls();
break;
case 'KeyM':
@@ -149,15 +137,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, showFlash, toggleFullscreen]);
// Each flash clears itself; keying the node on `id` restarts the animation
// when the same action fires twice in a row.
useEffect(() => {
if (!flash) return;
const timer = setTimeout(() => setFlash(null), 500);
return () => clearTimeout(timer);
}, [flash]);
}, [showControls, toggleFullscreen]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
@@ -211,40 +191,29 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
showFlash(video.paused ? 'play' : 'pause');
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
// Facts worth showing under the player, skipping anything the API didn't send.
const facts = [
item?.width && item?.height ? `${item.width}×${item.height}` : null,
item?.fps ? `${Math.round(item.fps)} fps` : null,
duration > 0 ? formatTime(duration) : null,
item?.fileSize ? formatFileSize(item.fileSize) : null,
].filter(Boolean);
const flashIcons = { play: Play, pause: Pause, back: Rewind, forward: FastForward } as const;
const FlashIcon = flash ? flashIcons[flash.kind] : null;
if (notAuthenticated) {
return (
<div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
<p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
You don't have access to this {noun.toLowerCase()}.
</p>
<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={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
>
<ArrowLeft size={15} />
<ArrowLeft size={16} />
Go back
</button>
</div>
@@ -252,19 +221,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}
return (
<div className="h-full flex flex-col px-13 py-8 gap-4">
<div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className={clsx(quietLinkClass, "flex items-center gap-1.5 shrink-0")}
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={15} />
<ArrowLeft size={16} />
Back
</button>
{/* Player */}
<div
ref={containerRef}
className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
@@ -296,37 +265,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* 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-white/20 border-t-white animate-spin" />
</div>
)}
{/* Action flash — a badge that swells and fades on play, pause and seek. */}
{flash && FlashIcon && (
<div key={flash.id} className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="animate-flash-badge flex h-18.5 w-18.5 items-center justify-center rounded-full bg-player/55 backdrop-blur-[2px] text-on-dark">
<FlashIcon size={30} fill="currentColor" strokeWidth={1} />
</div>
<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'}`}>
{/* Gradients — top one carries the title, bottom one the controls */}
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-black/55 to-transparent" />
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/10 to-transparent" />
{/* Title plate */}
<div className={clsx(
"absolute inset-x-0 top-0 px-5 pt-4 flex flex-col gap-0.5 transition-transform duration-300",
controlsVisible ? "translate-y-0" : "-translate-y-2"
)}>
<p className="font-data text-[10.5px] uppercase tracking-[0.14em] text-on-dark/55">
{noun}
</p>
<p className="font-heading text-[22px] leading-tight text-on-dark truncate">
{item?.title || "(No title)"}
</p>
</div>
{/* 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
@@ -336,42 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setHoverTime(ratio * duration);
}}
onMouseLeave={() => setHoverTime(null)}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
{/* Ghost fill up to the cursor, so the scrub reads before you commit */}
{hoverTime !== null && (
<div className="absolute inset-y-0 left-0 bg-white/25 rounded-full" style={{ width: `${hoverPct}%` }} />
)}
<div className="absolute inset-y-0 left-0 bg-accent-light rounded-full" style={{ width: `${progressPct}%` }} />
<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-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
{/* Time under the cursor */}
{hoverTime !== null && (
<div
className="absolute bottom-4 -translate-x-1/2 rounded-md bg-player/85 px-1.5 py-0.5 font-data text-[11px] text-on-dark tabular-nums pointer-events-none"
style={{ left: `${hoverPct}%` }}
>
{formatTime(hoverTime)}
</div>
)}
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
<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" />}
@@ -379,7 +303,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
@@ -388,18 +312,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
className="w-16 accent-on-dark cursor-pointer"
/>
</div>
</div>
<span className="font-data text-xs tabular-nums text-on-dark/80">
<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-white/70 transition-colors">
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
@@ -407,26 +331,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div>
</div>
{error && <p className="font-body text-[13px] text-live-red">{error}</p>}
{error && <div className="text-error text-sm mt-3">{error}</div>}
{!mediaLoaded && !error && (
<p className="font-data text-[12px] text-faint-mono">loading video…</p>
<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>
)}
<Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<div className="min-w-0">
<p className={kickerClass}>{noun}</p>
<p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
{item?.title || "(No title)"}
</p>
<p className="font-body text-[13px] text-muted pt-1">{timeAgo}</p>
</div>
{facts.length > 0 && (
<p className="font-data text-[12px] text-faint-mono tabular-nums">
{facts.join(" · ")}
</p>
)}
</Card>
<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>
);
};

View File

@@ -1,24 +1,23 @@
import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips";
import { useAuth } from "../utils/AuthContext";
import { stringToDate } from "../utils/utils";
import type { Clip } from "../utils/types";
const MyClips = () => {
const { user, loading } = useAuth();
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
if (!user) return;
getClips()
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load clips"));
}, [user]);
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: number) => {
try {
@@ -29,45 +28,34 @@ const MyClips = () => {
}
};
if (loading) return null;
if (!user) return <NoAccessPage />;
return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="Clips">
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
</PageHeader>
<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 lg:grid-cols-4 gap-[18px]">
{clips.map((clip) => (
<TimeGroupedGrid
items={clips}
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
))}
</div>
)}
/>
{clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<h2 className="font-heading text-[25px]">No clips yet.</h2>
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Cut one from the stream panel, or{" "}
<Link to="/create" className="text-accent hover:text-accent-hover">upload a file</Link>{" "}
to trim it down here.
</p>
{clips.length === 0 && !error && !loading && (
<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="font-body text-[13px] text-live-red mt-6">{error}</p>}
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
</div>
);
}

View File

@@ -1,24 +1,23 @@
import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods";
import { useAuth } from "../utils/AuthContext";
import { stringToDate } from "../utils/utils";
import type { Vod } from "../utils/types";
const MyVods = () => {
const { user, loading } = useAuth();
const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
if (!user) return;
getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, [user]);
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: number) => {
try {
@@ -29,46 +28,37 @@ const MyVods = () => {
}
};
if (loading) return null;
if (!user) return <NoAccessPage />;
return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="VoDs">
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
</PageHeader>
<div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]">
{vods.map((vod) => (
<TimeGroupedGrid
items={vods}
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods"
playerPath="/vod"
itemLabel="vod"
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)}
/>
))}
</div>
)}
/>
{vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Ranges you save from the stream panel land here, full length and untouched.
</p>
{vods.length === 0 && !error && !loading && (
<div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div>
)}
{error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
{error && <p className="text-sm text-error mt-4">{error}</p>}
</div>
);
};

View File

@@ -1,17 +1,24 @@
import {Lock} from "lucide-react";
import { Lock } from 'lucide-react';
import {useNavigate} from "react-router-dom";
const NotFound = () => {
const navigate = useNavigate();
const goHomePage = () => {
navigate("/")
}
const NoAccessPage = () => {
return (
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
Log in to see this page
This page does not exist.
</h2>
<p className="text-sm text-faint-mono/70 max-w-xs">
Sign in with Google to view your clips and account details.
<a onClick={goHomePage} className={"font-bold cursor-pointer"}>Click here</a> to get back to the home page.
</p>
</div>
);
}
export default NoAccessPage;
export default NotFound;

View File

@@ -1,13 +1,11 @@
import { useState } from "react";
import Card from "../components/Card.tsx";
import PageHeader from "../components/PageHeader.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
import { useAuth } from "../auth/useAuth.ts";
import { formatLocalDate } from "../utils/utils.ts";
import { labelClass, chipButtonClass } from "../utils/styles.ts";
const Profile = () => {
const { user, loading } = useAuth();
const { user } = useAuth();
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
@@ -15,54 +13,67 @@ const Profile = () => {
if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
setTimeout(() => setCopied(false), 2000);
};
if (loading) return null;
if (!user) return <NoAccessPage />;
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
Please log in to view your profile.
</div>
);
}
return (
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<PageHeader kicker="Account" title="Profile" />
<div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
<Card className="p-7 mb-[26px] flex items-center gap-5">
<Box className="p-6 mb-4 flex items-center gap-5">
<img
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt=""
className="w-[72px] h-[72px] rounded-full shadow-card"
alt="Profile picture"
className="w-20 h-20 rounded-full shadow-sm"
/>
<div className="flex flex-col gap-0.5">
<p className="font-heading text-[25px] leading-tight">{user.name}</p>
<p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
<p className="font-data text-[11px] text-faint-mono pt-1">
member since {formatLocalDate(user.createdAt)}
<div>
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
<p className="text-sm text-text-secondary">@{user.username}</p>
<p className="text-sm text-text-secondary">{user.email}</p>
<p className="text-xs text-muted mt-1">
Member since {formatLocalDate(user.createdAt)}
</p>
</div>
</Card>
</Box>
<Card className="p-7">
<h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
Keep it private: anyone holding it can stream to your account.
<Box className="p-6">
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
<p className="text-sm text-text-secondary mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming.
Keep it private — anyone with this key can stream to your account.
</p>
<p className={labelClass}>Key</p>
<div className="flex items-center gap-2.5 pt-2">
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
<div className="flex items-center gap-2">
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
</div>
<button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
<button
onClick={() => setKeyVisible(v => !v)}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<button className={chipButtonClass} onClick={handleCopy}>
{copied ? "Copied" : "Copy"}
<button
onClick={handleCopy}
title="Copy key"
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
>
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
</button>
</div>
</Card>
</Box>
</div>
);
};

View File

@@ -1,72 +1,80 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx";
import Card from "../components/Card.tsx";
import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import IsLivePulse from "../components/IsLivePulse.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
import Box from "../components/Box.tsx";
import CardHeader from "../components/CardHeader.tsx";
import MarkerList from "../components/stream/MarkerList.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import { useAuth } from "../auth/useAuth.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSection, clipSection } from "../utils/api/media.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { Marker, StreamStatus } from "../utils/types.ts";
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatLocalDate, stringToDate } from "../utils/utils.ts";
/** Ten-minute gridlines behind the marker ticks. */
const GRID_INTERVAL_SECONDS = 600;
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
const useCopy = () => {
const [copied, setCopied] = useState<string | null>(null);
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const copy = (key: string, text: string) => {
navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
};
return { copied, copy };
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-error mt-0.5 flex-shrink-0" />
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-olive flex-shrink-0" />
<p className="text-text-body">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-terracotta animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-text-secondary mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" />
</div>
</div>
);
};
const StreamControl = () => {
const { user, loading } = useAuth();
const { copied, copy } = useCopy();
const { user } = useAuth();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [now, setNow] = useState(Date.now());
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null);
const markerInputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<number[]>([]);
const [inValue, setInValue] = useState("");
const [outValue, setOutValue] = useState("");
const [rangeName, setRangeName] = useState("");
const [rangeError, setRangeError] = useState<string | null>(null);
const [rangeBusy, setRangeBusy] = useState(false);
const [clipDuration, setClipDuration] = useState(30);
const [clipName, setClipName] = useState("");
const [clipTitle, setClipTitle] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipError, setClipError] = useState<string | null>(null);
const [keyVisible, setKeyVisible] = useState(false);
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
const [startMarkerId, setStartMarkerId] = useState<string>("");
const [endMarkerId, setEndMarkerId] = useState<string>("");
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
const [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false;
const startMs = streamStatus?.startTime ? stringToDate(streamStatus.startTime).getTime() : null;
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
const elapsed = formatTimecode(elapsedSeconds);
// ── Polling ─────────────────────────────────────────────────────────────
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -76,26 +84,25 @@ const StreamControl = () => {
}, [user]);
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!user) return;
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
}, [user]);
if (!user || !streamStatus?.id) {
setStreamDetails(null);
return;
}
getStreamHistory(user.id)
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
.catch(() => {});
}, [user, streamStatus?.id]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers([...data]
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.then((data) => setMarkers(data
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
.catch(() => {});
};
useEffect(() => {
if (!isStreaming) {
setMarkers([]);
setSelected([]);
return;
}
refreshMarkers();
@@ -103,495 +110,212 @@ const StreamControl = () => {
return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]);
// One interval for the whole pending set, rebuilt only when that set changes.
const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(",");
useEffect(() => {
if (!pendingKey) return;
const uuids = pendingKey.split(",");
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
if (pending.length === 0) return;
const interval = setInterval(() => {
uuids.forEach((uuid) => {
getJob(uuid)
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: job)))
pending.forEach((job) => {
getJob(job.uuid)
.then((result) => {
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: j));
})
.catch(() => {});
});
}, 1000);
return () => clearInterval(interval);
}, [pendingKey]);
}, [jobs]);
// ── Marker helpers ──────────────────────────────────────────────────────
const offsetOf = (marker: Marker) =>
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
const toggleMarker = (id: number) => {
const next = selected.includes(id)
? selected.filter((current) => current !== id)
: selected.length < 2 ? [...selected, id] : [selected[1], id];
setSelected(next);
setRangeError(null);
if (next.length !== 2 || startMs === null) return;
const offsets = next
.map((markerId) => markers.find((marker) => marker.id === markerId))
.filter((marker): marker is Marker => marker !== undefined)
.map(offsetOf)
.sort((a, b) => a - b);
if (offsets.length !== 2) return;
setInValue(formatTimecode(offsets[0]));
setOutValue(formatTimecode(offsets[1]));
const addJob = (uuid: string, label: string) => {
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
};
// 'M' anywhere on the page jumps to the marker field.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== "m" && event.key !== "M") return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
event.preventDefault();
markerInputRef.current?.focus();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────
const addJob = (uuid: string, label: string) =>
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
const handleAddMarker = async () => {
if (markerBusy) return;
setMarkerBusy(true);
const handleAddMarker = async (message: string) => {
setMarkerError(null);
try {
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
setMarkerMessage("");
await createMarker(message);
refreshMarkers();
return true;
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
} finally {
setMarkerBusy(false);
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
return false;
}
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Pick a start and an end marker from the timeline");
return;
}
try {
const uuid = await saveSectionByMarkers(
Number(startMarkerId),
Number(endMarkerId),
markerSaveTitle || undefined,
markerSaveDescription || undefined
);
addJob(uuid, "Save (by markers)");
setStartMarkerId("");
setEndMarkerId("");
setMarkerSaveTitle("");
setMarkerSaveDescription("");
} catch (err) {
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
}
};
const handleDeleteMarker = async (id: number) => {
try {
await deleteMarker(id);
setSelected((prev) => prev.filter((current) => current !== id));
if (startMarkerId === String(id)) setStartMarkerId("");
if (endMarkerId === String(id)) setEndMarkerId("");
refreshMarkers();
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
}
};
const inSeconds = parseTimecode(inValue);
const outSeconds = parseTimecode(outValue);
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
? formatTime(outSeconds - inSeconds)
: null;
const handleSaveRange = async () => {
setRangeError(null);
if (startMs === null) {
setRangeError("No stream is running to take a range from.");
return;
}
if (inSeconds === null || outSeconds === null) {
setRangeError("Timecodes read as hh:mm:ss.");
return;
}
if (outSeconds <= inSeconds) {
setRangeError("The out point has to come after the in point.");
return;
}
if (outSeconds > elapsedSeconds) {
setRangeError("That out point hasn't been streamed yet.");
return;
}
setRangeBusy(true);
try {
const uuid = await saveSection(
new Date(startMs + inSeconds * 1000).toISOString(),
new Date(startMs + outSeconds * 1000).toISOString(),
rangeName || undefined
);
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
clearSelection();
} catch (err) {
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
} finally {
setRangeBusy(false);
}
const handleSelectMarker = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
setEndMarkerId(idStr);
};
const clearSelection = () => {
setSelected([]);
setInValue("");
setOutValue("");
setRangeName("");
setRangeError(null);
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipName || undefined);
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
setClipName("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
}
};
// ── Timeline ────────────────────────────────────────────────────────────
const gridlines = useMemo(() => {
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
return Array.from(
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
Please log in to control your stream.
</div>
);
}, [elapsedSeconds]);
}
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
if (loading) return null;
if (!user) return <NoAccessPage />;
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Ingest" title="Stream panel" />
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
{/* ── Main column ───────────────────────────────────────── */}
<div className="flex flex-col gap-[26px]">
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Server credentials</h2>
{isStreaming ? (
<div className="flex items-center gap-2">
<IsLivePulse />
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
</div>
) : (
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
)}
</div>
<p className={labelClass}>Ingest URL</p>
<div className="flex items-center gap-2.5 pt-2">
<input
type="text"
readOnly
value={ingestUrl ?? ""}
placeholder="asking the server…"
className={clsx(monoInputClass, "flex-1")}
/>
<button
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
disabled={!ingestUrl}
onClick={() => ingestUrl && copy("url", ingestUrl)}
>
{copied === "url" ? "Copied" : "Copy"}
</button>
</div>
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
<div className="flex items-center gap-2.5 pt-2">
<div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
</div>
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
</button>
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
{copied === "key" ? "Copied" : "Copy"}
</button>
</div>
<p className="pt-3 font-body text-[13px] text-muted">
Keep this private — anyone holding the key can stream to your account.
</p>
</Card>
{isStreaming ? (
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Live markers</h2>
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
</div>
<div className="flex items-center gap-3">
<input
ref={markerInputRef}
type="text"
value={markerMessage}
onChange={(event) => setMarkerMessage(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
placeholder="What just happened? (optional)"
className={clsx(inputClass, "flex-1")}
/>
<Button
variant="dark"
onClick={handleAddMarker}
disabled={markerBusy}
className="whitespace-nowrap px-5 py-3"
>
+ Drop marker at {elapsed}
</Button>
</div>
<p className="pt-2.5 font-body text-[13px] text-muted">
or press{" "}
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
{" "}to jump here
</p>
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
{/* Timeline overview */}
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
{gridlines.map((left, index) => (
<span
key={index}
className="absolute inset-y-0 w-px bg-inactive/60"
style={{ left: `${left}%` }}
/>
))}
{markers.map((marker) => {
const left = elapsedSeconds > 0
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
: 0;
return (
<span
key={marker.id}
title={marker.message}
className={clsx(
"absolute inset-y-0 w-[2px]",
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
)}
style={{ left: `${left}%` }}
/>
);
})}
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
</div>
{/* Marker list */}
<div className="flex flex-col gap-2 pt-5">
{markers.length === 0 && (
<p className="font-body text-[13px] text-muted py-2">
No markers yet. Drop one the moment something happens — you can cut around it later.
</p>
)}
{markers.map((marker) => {
const isSelected = selected.includes(marker.id);
const offset = offsetOf(marker);
return (
<div key={marker.id} className="group flex items-center gap-1">
<button
onClick={() => toggleMarker(marker.id)}
className={clsx(
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
isSelected
? "bg-armed inset-shadow-armed"
: "bg-card inset-shadow-hairline hover:bg-fields"
)}
>
<span className={clsx(
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
)}>
{isSelected && <Check size={12} strokeWidth={3} />}
</span>
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
{formatTimecode(offset)}
</span>
<span className="flex-1 font-body text-sm text-text-strong truncate">
{marker.message}
</span>
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
{toRelativeOffset(elapsedSeconds - offset)}
</span>
</button>
<button
onClick={() => handleDeleteMarker(marker.id)}
title="Remove marker"
className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
>
<X size={14} />
</button>
</div>
);
})}
</div>
</Card>
) : (
<Card className="p-8 flex flex-col gap-2">
<p className={kickerClass}>Off air</p>
<h2 className="font-heading text-[27px] leading-[1.25]">Nothing is coming in right now.</h2>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty">
Point OBS at the ingest URL above with your stream key, and this panel fills
in — elapsed time, markers, and the range cutter.
</p>
</Card>
)}
{/* Hero status bar */}
<Box className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4",
isStreaming ? "border-l-live" : "border-l-hairline"
)}>
<div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-3.5">
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
</span>
<div>
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
{isStreaming && streamDetails?.startDate ? (
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
) : !isStreaming ? (
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
{/* ── Side panel ────────────────────────────────────────── */}
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
{isStreaming && (
<>
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="font-heading text-[22px]">Make something from a range</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
{selected.length === 2 && rangeLength
? `Two markers selected — ${inValue} to ${outValue}.`
: "Tick two markers, or type a timecode by hand."}
</p>
</div>
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
</Box>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-in">In</label>
<input
id="range-in"
value={inValue}
onChange={(event) => setInValue(event.target.value)}
placeholder="00:00:00"
className={clsx(monoInputClass, "bg-card")}
/>
{isStreaming && (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
{/* Marker timeline. First on narrow screens, where marking
something as it happens beats the action forms for urgency. */}
<MarkerList
className="lg:order-last lg:sticky lg:top-6"
markers={markers}
startMarkerId={startMarkerId}
endMarkerId={endMarkerId}
error={markerError}
onAdd={handleAddMarker}
onSelect={handleSelectMarker}
onDelete={handleDeleteMarker}
/>
{/* Forefront actions */}
<div className="flex flex-col gap-5">
<Box className="p-6 flex flex-col gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
{markers.length < 2 ? (
<p className="text-sm text-muted">Add at least two markers to save a section between them.</p>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
startMarker ? "border-terracotta text-terracotta bg-selected" : "border-dashed border-inactive text-muted"
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-inactive-strong">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-olive text-olive bg-selected" : "border-dashed border-inactive text-muted"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-out">Out</label>
<input
id="range-out"
value={outValue}
onChange={(event) => setOutValue(event.target.value)}
placeholder="00:00:00"
className={clsx(monoInputClass, "bg-card")}
/>
</div>
</div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
</>
)}
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
</Box>
<div className="flex items-baseline justify-between gap-3">
<span className="font-body text-[13.5px] text-text-body">Range length</span>
<span className="font-data text-[15px] text-text-primary tabular-nums">
{rangeLength ?? "—"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-name">Name</label>
<input
id="range-name"
value={rangeName}
onChange={(event) => setRangeName(event.target.value)}
placeholder="Give it a name"
className={clsx(inputClass, "bg-card")}
/>
</div>
{rangeError && <p className="font-body text-[13px] text-live-red">{rangeError}</p>}
<div className="flex flex-col gap-2.5">
<Button
onClick={handleSaveRange}
disabled={rangeBusy || rangeLength === null}
className="w-full py-3"
>
{rangeBusy ? "Queueing…" : "Save range as VOD"}
</Button>
<Button variant="ghost" onClick={clearSelection} className="w-full">
Clear selection
</Button>
</div>
<Box className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5">
<label className={labelClass}>Duration (seconds)</label>
<input
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
className={inputClass}
/>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-error">{clipError}</p>}
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
</Box>
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
Takes the last stretch of stream, counted back from this moment.
</p>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
) : (
<div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
<input
id="clip-duration"
type="number"
min={1}
max={180}
value={clipDuration}
onChange={(event) => setClipDuration(Number(event.target.value))}
className={clsx(monoInputClass, "bg-card")}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-name">Name</label>
<input
id="clip-name"
value={clipName}
onChange={(event) => setClipName(event.target.value)}
placeholder="Optional"
className={clsx(inputClass, "bg-card")}
/>
</div>
</div>
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
<Button
variant="olive"
onClick={handleClip}
disabled={clipDuration < 1 || clipDuration > 180}
className="w-full py-3"
>
Cut the last {clipDuration}s as a clip
</Button>
</div>
</>
)}
{jobs.length > 0 && (
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Background jobs</p>
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
</div>
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
</div>
)}
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
<p className={kickerClass}>How it works</p>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
Tick two markers to fill the range automatically, or type a timecode by hand.
Saving queues a background job — you can keep streaming while it runs.
</p>
)}
</Box>
</div>
</aside>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
/** What the user chose. "system" defers to the OS setting. */
type ThemePreference = "light" | "dark" | "system";
/** The theme actually on screen, once "system" has been resolved. */
type ResolvedTheme = "light" | "dark";
type ThemeContextValue = {
/** The stored choice, which may be "system". */
preference: ThemePreference;
/** The theme currently applied to the document. */
theme: ResolvedTheme;
/** Store a new preference and apply it immediately. */
setPreference: (preference: ThemePreference) => void;
/** Flip between light and dark, pinning the result as an explicit choice. */
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export { ThemeContext };
export type { ThemeContextValue, ThemePreference, ResolvedTheme };

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { ThemeContext } from "./ThemeContext.ts";
import type { ResolvedTheme, ThemePreference } from "./ThemeContext.ts";
/** Shared with the pre-paint script in index.html — keep the two in step. */
const STORAGE_KEY = "theme";
const DARK_QUERY = "(prefers-color-scheme: dark)";
const readPreference = (): ThemePreference => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
} catch {
// Storage blocked (private browsing, cookies disabled) — follow the OS.
}
return "system";
};
const readSystemTheme = (): ResolvedTheme =>
window.matchMedia(DARK_QUERY).matches ? "dark" : "light";
/**
* Owns the single source of truth for the active theme. Nothing reads colours
* from here: the palette lives in index.css and switches on the `data-theme`
* attribute this provider writes, so components keep using the semantic
* utilities (`bg-card`, `text-text-primary`) and follow along for free.
*/
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [preference, setPreferenceState] = useState<ThemePreference>(readPreference);
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(readSystemTheme);
// Keep following the OS while the preference is "system".
useEffect(() => {
const query = window.matchMedia(DARK_QUERY);
const onChange = (event: MediaQueryListEvent) =>
setSystemTheme(event.matches ? "dark" : "light");
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const theme: ResolvedTheme = preference === "system" ? systemTheme : preference;
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
const setPreference = useCallback((next: ThemePreference) => {
setPreferenceState(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Storage blocked — the choice still applies for this session.
}
}, []);
const toggleTheme = useCallback(() => {
setPreference(theme === "dark" ? "light" : "dark");
}, [theme, setPreference]);
return (
<ThemeContext.Provider value={{ preference, theme, setPreference, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export default ThemeProvider;

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { ThemeContext } from "./ThemeContext.ts";
import type { ThemeContextValue } from "./ThemeContext.ts";
/** Access the centralised theme state. Must be used within a <ThemeProvider>. */
const useTheme = (): ThemeContextValue => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
export { useTheme };

View File

@@ -1,42 +0,0 @@
import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
import { getUser } from "./api/users.ts";
import type {User} from "./types.ts";
// for what the Auth object will look like
interface AuthContextValue {
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
loading: boolean;
}
interface AuthProviderProps {
children: ReactNode;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({children}: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
getUser()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
return (
<AuthContext.Provider value={{ user, setUser, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -1,12 +1,93 @@
const API_URL = import.meta.env.VITE_API_URL;
import {config} from "../../config.ts";
import type { APIResponse } from "../types.ts";
const API_URL = config.apiUrl;
class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => {
const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok;
};
let onUnauthorized: (() => void) | null = null;
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
export { API_URL, AuthError, isThumbnailAvailable };
export function registerUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
onSessionRefreshed = handler;
}
/**
* Exchanges the refresh token cookie for new access and refresh token cookies.
* Concurrent callers share a single request, so a burst of 401s only rotates
* the refresh token once.
*
* @returns true if the session was refreshed, false if it has ended (no valid
* refresh token). Throws on network errors so a dropped connection isn't
* mistaken for being logged out.
*/
function refreshSession(): Promise<boolean> {
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
return refreshInFlight;
}
async function requestRefresh(): Promise<boolean> {
const response = await fetch(API_URL + '/api/v1/users/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) return false;
const result: APIResponse = await response.json();
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
return true;
}
/**
* Performs a request against the API, sending session cookies. An expired
* access token is refreshed and the request retried once, so callers only see
* an AuthError when the session has really ended — at which point the
* registered unauthorized handler drops the UI back to a logged out state.
* Returns the raw response so callers that need something other than JSON
* (blobs, streams) can read it themselves.
*/
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
const send = () => fetch(url, { ...options, credentials: 'include' });
let response = await send();
if (response.status == 401 && await refreshSession()) {
response = await send();
}
if (response.status == 401) {
onUnauthorized?.();
throw new AuthError();
}
// Logged in, but not allowed to access this resource: don't end the session.
if (response.status == 403) {
throw new AuthError();
}
if (!response.ok) {
const text = await response.text();
let message = text;
try { message = JSON.parse(text).message ?? text; } catch { /* not JSON, use raw text */ }
throw new Error(message || `Request failed: ${response.status}`);
}
return response;
}
async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await apiFetchRaw(url, options);
const text = await response.text();
return text ? JSON.parse(text) : (undefined as T);
}
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };

View File

@@ -1,27 +1,17 @@
import type { APIResponse, Clip } from "../types.ts";
import { API_URL, AuthError } from "./client.ts";
import { API_URL, apiFetch } from "./client.ts";
const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/clips');
if (result.status === 'error') throw new Error(`Failed to fetch clips: ${result.message}`);
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`);
if (result.status === 'error') throw new Error(`Failed to fetch clip: ${result.message}`);
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
@@ -31,31 +21,20 @@ const getClipById = async (id: string): Promise<Clip | null> => {
const getClipMediaUrl = (id: string): string => API_URL + `/api/v1/clips/${id}/media`;
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/clips/${id}`, { method: 'DELETE' });
if (result?.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
export { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip };

View File

@@ -1,15 +1,16 @@
import type { APIResponse, JobResponse } from "../types.ts";
import { API_URL } from "./client.ts";
import { API_URL, apiFetch, apiFetchRaw } from "./client.ts";
const getJob = async (uuid: string): Promise<JobResponse> => {
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
const result: APIResponse = await response.json();
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/jobs/${uuid}`);
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
export { getJob };
const downloadJob = async (uuid: string): Promise<Blob> => {
const response = await apiFetchRaw(API_URL + `/api/v1/jobs/${uuid}/download`);
return response.blob();
};
export { getJob, downloadJob };

View File

@@ -1,40 +1,28 @@
import type { APIResponse, Marker } from "../types.ts";
import { API_URL } from "./client.ts";
import { API_URL, apiFetch } from "./client.ts";
const getMarkers = async (): Promise<Marker[]> => {
const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
const result: APIResponse = await response.json();
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers');
if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
return result.data;
};
const createMarker = async (message: string): Promise<Marker> => {
const response = await fetch(API_URL + '/api/v1/markers', {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
if (result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
return result.data;
};
const deleteMarker = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/markers/${id}`, { method: 'DELETE' });
if (result?.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
};
export { getMarkers, createMarker, deleteMarker };

View File

@@ -1,5 +1,5 @@
import type { APIResponse, VideoMetadata } from "../types.ts";
import { API_URL } from "./client.ts";
import { API_URL, apiFetch } from "./client.ts";
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
const formData = new FormData();
@@ -13,58 +13,48 @@ const compress = async (file: File, options: VideoMetadata): Promise<string> =>
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/media/compress', {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/save', {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ startTime, endTime, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/save/markers', {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/save/markers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
if (result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
return result.data.uuid;
};
const clipSection = async (duration: number, title?: string, description?: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/media/clip', {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/media/clip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ duration, title, description }),
});
const result: APIResponse = await response.json();
if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
if (result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
return result.data.uuid;
};

View File

@@ -1,43 +1,27 @@
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
import { API_URL } from "./client.ts";
import { API_URL, apiFetch } from "./client.ts";
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
try {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
return result.status === 'success' && result.data?.isStreaming === true;
} catch {
return false;
}
};
const getCurrentStream = async (): Promise<StreamStatus> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
const result: APIResponse = await response.json();
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
return result.data;
};
// The RTMP endpoint to point OBS at. Comes from the server so it stays right
// across environments rather than being baked into the bundle.
const getIngestUrl = async (): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/stream/metadata', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch ingest URL: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch ingest URL: ${result.message}`);
return result.data.url;
};
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
const result: APIResponse = await response.json();
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/stream/history/${userId}`);
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
return result.data;
};
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };
export { getStreamStatus, getCurrentStream, getStreamHistory };

View File

@@ -1,7 +1,17 @@
import type { APIResponse, User } from "../types.ts";
import { API_URL } from "./client.ts";
const login = async (googleToken: string): Promise<string> => {
// These deliberately use fetch directly rather than apiFetch: they run when
// there may be no session at all, and apiFetch's refresh-and-retry on 401
// would either loop or log the user out from under the auth provider.
/**
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
* cookies; only the access token lifetime is returned.
*
* @returns seconds until the access token expires
*/
const login = async (googleToken: string): Promise<number> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -14,9 +24,10 @@ const login = async (googleToken: string): Promise<string> => {
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
return result.data.accessTokenExpiresIn;
};
/** Revokes the session and clears the auth cookies. */
const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
@@ -24,9 +35,6 @@ const logout = async (): Promise<void> => {
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
const getUser = async (): Promise<User | null> => {

View File

@@ -1,27 +1,17 @@
import type { APIResponse, Vod } from "../types.ts";
import { API_URL, AuthError } from "./client.ts";
import { API_URL, apiFetch } from "./client.ts";
const getVods = async (): Promise<Vod[]> => {
const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/vods');
if (result.status === 'error') throw new Error(`Failed to fetch vods: ${result.message}`);
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch vods: ${errorResult.message}`);
}
const result: APIResponse = await response.json();
return result.data;
};
const getVodById = async (id: string): Promise<Vod | null> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`);
if (result.status === 'error') throw new Error(`Failed to fetch vod: ${result.message}`);
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch vod: ${response.status}`);
}
const result: APIResponse = await response.json();
return result.data;
};
@@ -31,31 +21,20 @@ const getVodById = async (id: string): Promise<Vod | null> => {
const getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
return result.data;
};
const deleteVod = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
};
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod };

View File

@@ -1,27 +0,0 @@
// Shared class strings for the recurring bits of chrome. Anything used on more
// than one page lives here so the pages stay readable and stay in step.
/** Field well — no border, a soft terracotta ring on focus. */
const inputClass =
"bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
"placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
/** Same well, for timecodes and other machine facts. */
const monoInputClass = inputClass.replace("font-body", "font-data");
/** Field label — sits 6–10px above its field. */
const labelClass = "text-[11.5px] font-body text-muted";
/** Kicker / eyebrow above a page title or inside a muted panel. */
const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
/** Small chip button: copy, reveal, toggles. */
const chipButtonClass =
"bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
"px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
/** Quiet text link, used for back links and cancels. */
const quietLinkClass =
"font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };

View File

@@ -32,11 +32,6 @@ type Clip = {
description: string,
duration: number,
createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
}
type Vod = {
@@ -46,11 +41,6 @@ type Vod = {
description: string,
duration: number,
createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
}
type JobResponse = {
@@ -65,7 +55,6 @@ type JobResponse = {
type StreamStatus = {
isStreaming: boolean,
id: number | null,
startTime: string | null
}
type StreamHistoryItem = {
@@ -83,11 +72,6 @@ type Marker = {
timestamp: string,
}
type StreamMetadata = {
url: string,
length: number
}
export type {
APIResponse,
VideoMetadata,
@@ -97,6 +81,5 @@ export type {
JobResponse,
StreamStatus,
StreamHistoryItem,
Marker,
StreamMetadata
Marker
}

View File

@@ -7,45 +7,6 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`;
}
// Sizes in MB/GB, as the design calls for. Input is bytes.
function formatFileSize(bytes: number): string {
const mb = bytes / 1_000_000;
if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
if (mb >= 10) return `${Math.round(mb)} MB`;
return `${mb.toFixed(1)} MB`;
}
// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
function formatTimecode(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
}
// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
// half-typed field doesn't reject the user mid-keystroke.
function parseTimecode(value: string): number | null {
const trimmed = value.trim();
if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
const parts = trimmed.split(':').map(Number);
if (parts.some(isNaN)) return null;
// Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
if (m > 59 || s > 59) return null;
return h * 3600 + m * 60 + s;
}
// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
// first few hours — that's the scale you actually think in mid-stream.
function toRelativeOffset(secondsAgo: number): string {
const m = Math.round(Math.max(0, secondsAgo) / 60);
if (m < 180) return `−${m}m`;
return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
}
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date {
@@ -60,6 +21,12 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
// Clock time only, no date — markers within a stream are all from the same day,
// so the date would just be noise beside them.
function formatLocalTime(dateString: string): string {
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function dateToTimeAgo(date: Date): string {
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [
@@ -79,4 +46,52 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// Weeks start on Monday.
function startOfWeek(date: Date): Date {
const day = startOfDay(date);
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
return day;
}
// Buckets a date relative to now. Anything before last month falls back to its
// month and year, so older items still group sensibly.
function timeRangeLabel(date: Date, now: Date = new Date()): string {
const today = startOfDay(now);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const thisWeek = startOfWeek(now);
const lastWeek = new Date(thisWeek);
lastWeek.setDate(thisWeek.getDate() - 7);
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= thisWeek) return "This Week";
if (date >= lastWeek) return "Last Week";
if (date >= thisMonth) return "This Month";
if (date >= lastMonth) return "Last Month";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
type TimeGroup<T> = { label: string, items: T[] };
// Expects items already sorted newest first; groups keep that order.
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
const now = new Date();
const groups: TimeGroup<T>[] = [];
for (const item of items) {
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
const last = groups[groups.length - 1];
if (last?.label === label) last.items.push(item);
else groups.push({ label, items: [item] });
}
return groups;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
export type { TimeGroup };

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface Window {
_env_?: {
VITE_API_URL?: string;
VITE_FRONTEND_URL?: string;
VITE_GOOGLE_CLIENT_ID?: string;
};
}

View File

@@ -1,13 +1,12 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite";
export default defineConfig(({ mode }) => { // change to function form
const env = loadEnv(mode, process.cwd(), '') // load all env vars
return {
plugins: [react(), tailwindcss(), flowbiteReact()],
plugins: [react(), tailwindcss()],
preview: {
port: 5173,
strictPort: true,

View File

@@ -1,6 +1,6 @@
FROM tiangolo/nginx-rtmp
FROM tiangolo/nginx-rtmp:latest-2026-09-07
RUN apt-get update && \
RUN apt-get update -o Acquire::Check-Valid-Until=false && \
apt-get install -y --no-install-recommends gettext-base && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

10
pom.xml
View File

@@ -90,6 +90,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Database Migration Tools -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -1,15 +1,23 @@
package com.ddf.vodsystem;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableConfigurationProperties(AuthProperties.class)
public class VodSystemApplication {
/**
* Application entry point; boots the Spring context.
*
* @param args command-line arguments passed to Spring Boot
*/
public static void main(String[] args) {
SpringApplication.run(VodSystemApplication.class, args);
}

View File

@@ -14,6 +14,11 @@ import java.util.concurrent.Executor;
public class AsyncConfig implements AsyncConfigurer {
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
/**
* Provides the bounded thread pool that runs ffmpeg-backed {@code @Async} media tasks.
*
* @return the configured {@link Executor} registered as {@code ffmpegTaskExecutor}
*/
@Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
@@ -25,6 +30,11 @@ public class AsyncConfig implements AsyncConfigurer {
return executor;
}
/**
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
*
* @return the async uncaught-exception handler
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->

View File

@@ -12,6 +12,11 @@ public class CorsConfig {
@Value("${frontend.url}")
private String frontendUrl;
/**
* Configures CORS for the API, allowing credentialed requests from the configured frontend origin.
*
* @return a {@link WebMvcConfigurer} that registers the CORS mappings for {@code /api/v1/**}
*/
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {

View File

@@ -1,10 +1,12 @@
package com.ddf.vodsystem.configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -22,6 +24,14 @@ public class SecurityConfig {
this.jwtFilter = jwtFilter;
}
/**
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
* installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules.
*
* @param http the HTTP security builder to configure
* @return the built {@link SecurityFilterChain}
* @throws Exception if the security configuration cannot be built
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@@ -30,13 +40,17 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
.requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/users/me").authenticated()
// authenticated by the refresh token cookie, not the (possibly expired) access token
.requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated()
)
// 401 rather than the default 403, so clients know to refresh their session
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

View File

@@ -0,0 +1,15 @@
package com.ddf.vodsystem.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}

View File

@@ -6,14 +6,12 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/v1/clips")
@@ -26,6 +24,11 @@ public class ClipController {
this.clipService = clipService;
}
/**
* Lists all clips belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's clips as {@link ClipResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser();
@@ -41,6 +44,12 @@ public class ClipController {
);
}
/**
* Retrieves a single clip owned by the authenticated user.
*
* @param id the ID of the clip to retrieve
* @return {@code 200 OK} wrapping the clip as a {@link ClipResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(id);
@@ -54,6 +63,13 @@ public class ClipController {
);
}
/**
* Applies a partial update to a clip's metadata; null fields are left unchanged.
*
* @param id the ID of the clip to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated clip as a {@link ClipResponse} DTO
*/
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) {
@@ -65,6 +81,12 @@ public class ClipController {
);
}
/**
* Deletes a clip and its associated files.
*
* @param id the ID of the clip to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(id);
@@ -78,6 +100,13 @@ public class ClipController {
);
}
/**
* Streams the clip's video file inline, supporting HTTP range requests.
*
* @param id the ID of the clip to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id);
@@ -95,8 +124,15 @@ public class ClipController {
.body(resource);
}
/**
* Streams the clip's thumbnail image with a public cache policy.
*
* @param id the ID of the clip whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
@@ -104,6 +140,8 @@ public class ClipController {
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
@@ -116,11 +154,7 @@ public class ClipController {
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
clip.getCreatedAt(),
clip.getWidth(),
clip.getHeight(),
clip.getFps(),
clip.getFileSize()
clip.getCreatedAt()
);
}
}

View File

@@ -18,6 +18,12 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String ERROR = "error";
/**
* Handles malformed multipart requests, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage());
@@ -25,6 +31,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles requests missing a required file part, returning {@code 400 Bad Request}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage());
@@ -32,6 +44,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles unsupported request content types, returning {@code 415 Unsupported Media Type}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
@@ -39,6 +57,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
}
/**
* Handles invalid arguments, returning {@code 400 Bad Request} with the exception message.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage());
@@ -46,6 +70,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* Handles lookups for a non-existent job, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage());
@@ -53,6 +83,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles requests for a job that is still running, returning {@code 202 Accepted}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage());
@@ -60,6 +96,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
}
/**
* Handles ffmpeg processing failures, returning {@code 500 Internal Server Error}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage());
@@ -67,6 +109,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
/**
* Handles unauthenticated or unauthorized access, returning {@code 401 Unauthorized}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage());
@@ -74,6 +122,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
/**
* Handles an unrecognised stream key, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage());
@@ -81,6 +135,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles an attempt to start a stream while one is already active, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage());
@@ -88,6 +148,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
/**
* Handles file storage failures, returning {@code 500 Internal Server Error}.
*
* @param e the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e);
@@ -95,6 +161,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
/**
* Handles lookups for a non-existent clip, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage());
@@ -102,6 +174,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles a clip whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
@@ -109,6 +187,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
/**
* Handles lookups for a non-existent VoD, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage());
@@ -116,6 +200,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
/**
* Handles a VoD whose media file exists but is not readable, returning {@code 503 Service Unavailable}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
@@ -123,6 +213,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response);
}
/**
* Handles actions that require an active stream when none exists, returning {@code 409 Conflict}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage());
@@ -130,6 +226,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
/**
* Handles lookups for a non-existent marker, returning {@code 404 Not Found}.
*
* @param ex the thrown exception
* @return an error {@link APIResponse}
*/
@ExceptionHandler(MarkerNotFound.class)
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
logger.error("MarkerNotFound: {}", ex.getMessage());

View File

@@ -28,6 +28,13 @@ public class JobController {
this.jobRegistryService = jobRegistryService;
}
/**
* Retrieves the status and progress of an asynchronous job.
*
* @param uuid the UUID of the job to look up
* @return {@code 200 OK} wrapping the job's state as a {@link JobResponse} DTO
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
*/
@GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
@@ -47,6 +54,15 @@ public class JobController {
));
}
/**
* Downloads the output file produced by a completed job.
*
* @param uuid the UUID of the job whose output to download
* @return {@code 200 OK} with the output file as a downloadable resource
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
* @throws NotReadyException if the job has not yet produced a downloadable file
* @throws ClipNotFound if the job's output file no longer exists on disk
*/
@GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);

View File

@@ -22,6 +22,11 @@ public class MarkerController {
this.markerService = markerService;
}
/**
* Lists all markers belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's markers as {@link MarkerResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream()
@@ -33,6 +38,12 @@ public class MarkerController {
);
}
/**
* Retrieves a single marker owned by the authenticated user.
*
* @param id the ID of the marker to retrieve
* @return {@code 200 OK} wrapping the marker as a {@link MarkerResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(id);
@@ -42,6 +53,12 @@ public class MarkerController {
);
}
/**
* Creates a marker at the current moment on the user's active stream.
*
* @param request the marker payload carrying the marker message
* @return {@code 200 OK} wrapping the created marker as a {@link MarkerResponse} DTO
*/
@PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message());
@@ -51,6 +68,12 @@ public class MarkerController {
);
}
/**
* Deletes a marker owned by the authenticated user.
*
* @param id the ID of the marker to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id);

View File

@@ -26,6 +26,14 @@ public class MediaController {
this.mediaService = mediaService;
}
/**
* Starts an asynchronous job to compress an uploaded video file.
*
* @param file the uploaded video to compress
* @param options trim window, resolution, fps, and target size for the output
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if saving the upload to temporary storage fails
*/
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file,
@@ -40,6 +48,13 @@ public class MediaController {
}
/**
* Starts an asynchronous job to save a section of the user's stream between two timestamps as a VoD.
*
* @param saveSectionRequest the start/end timestamps and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
@@ -55,6 +70,13 @@ public class MediaController {
));
}
/**
* Starts an asynchronous job to save the section of the user's stream between two markers as a VoD.
*
* @param saveSectionByMarkerRequest the start/end marker IDs and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
@@ -70,6 +92,13 @@ public class MediaController {
));
}
/**
* Starts an asynchronous job to save the last few seconds of the user's stream as a clip.
*
* @param clipSectionRequest the clip duration in seconds and optional title/description
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if reading the stream segments fails
*/
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {

View File

@@ -1,17 +1,14 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import java.util.Optional;
@@ -21,25 +18,20 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success";
/** Matches the port and application name in nginx/default.conf.template. */
private static final int RTMP_PORT = 1935;
private static final String RTMP_APPLICATION = "live";
private final StreamService streamService;
@Value("${stream.ingest.url:}")
private String configuredIngestUrl;
@Value("${frontend.url:}")
private String frontendUrl;
@Value("${stream.max-length}")
private Float streamLength;
public StreamController(StreamService streamService) {
this.streamService = streamService;
}
/**
* Starts a new stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish} RTMP callback when a broadcast begins.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} wrapping the newly started {@link Stream}
*/
// Called by nginx on_publish
@PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
@@ -48,6 +40,14 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
}
/**
* Ends the active stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish_done} RTMP callback when a broadcast stops.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the stream was ended
*/
// Called by nginx on_publish_done
@PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
@@ -56,6 +56,14 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
}
/**
* Records a liveness heartbeat for the active stream, keeping it from being reaped as stale.
* <p>
* Called periodically by nginx via its {@code on_update} RTMP callback.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the heartbeat was recorded
*/
// Called by nginx on_update
@PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
@@ -63,62 +71,31 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
}
/**
* Retrieves the stream history for the given user.
*
* @param userId the ID of the user whose stream history to retrieve; must be the authenticated user
* @return {@code 200 OK} wrapping the user's past and current {@link Stream}s
*/
@GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
@GetMapping("/metadata")
public ResponseEntity<APIResponse<StreamMetadataResponse>> getIngestEndpoint() {
return ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Stream information endpoint retrieved",
new StreamMetadataResponse(resolveIngestUrl(), streamLength)));
}
/**
* The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
* needed when the RTMP port is reached through a different host or a non-default port.
* Otherwise it is derived from the frontend's host, which is where docker-compose
* publishes the nginx-rtmp container.
* Reports whether the authenticated user is currently streaming.
*
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
* the active stream's ID
*/
private String resolveIngestUrl() {
if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
return configuredIngestUrl.trim();
}
String host = hostOf(frontendUrl);
return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
}
private static String hostOf(String url) {
if (url == null || url.isBlank()) {
return null;
}
String trimmed = url.trim();
try {
String host = URI.create(trimmed).getHost();
if (host != null && !host.isBlank()) {
return host;
}
} catch (IllegalArgumentException e) {
logger.warn("Could not parse frontend.url ({})", trimmed);
}
// A bare "example.net" or "example.net:8080" parses as a path, not a host.
String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
return bare.isBlank() ? null : bare;
}
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(true, value.getId(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(false, null, null))));
new StreamResponse(false, null))));
}
}

View File

@@ -1,17 +1,21 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.SessionResponse;
import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Optional;
@RestController
@@ -19,14 +23,26 @@ import java.util.Optional;
public class UserController {
private final UserService userService;
@Value("${jwt.expiration}")
private long jwtExpiration;
private final Duration jwtExpiration;
private final Duration refreshTokenExpiration;
private static final String SUCCESS = "success";
public UserController(UserService userService) {
private static final String ACCESS_COOKIE_PATH = "/";
private static final String REFRESH_COOKIE_PATH = "/api/v1/users";
public UserController(UserService userService,
AuthProperties props) {
this.userService = userService;
this.jwtExpiration = props.expiration().jwt();
this.refreshTokenExpiration = props.expiration().refreshToken();
}
/**
* Returns the currently authenticated user's profile.
*
* @return {@code 200 OK} wrapping the authenticated {@link User}
* @throws NotAuthenticated if no valid session is present
*/
@GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser();
@@ -40,42 +56,111 @@ public class UserController {
);
}
/**
* Authenticates a user from a Google ID token and starts a session.
* <p>
* Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
* (path {@code /api/v1/users}). Neither token is returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the cookies are written to
* @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse}
* @throws NotAuthenticated if the Google ID token is rejected
*/
@PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true)
.maxAge(jwtExpiration / 1000)
.sameSite(request.isSecure() ? "None" : "Lax")
.secure(request.isSecure())
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
TokenPackage tokenPackage = userService.login(token.token());
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
new APIResponse<>(SUCCESS, "Logged in successfully", sessionResponse())
);
}
@PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) {
ResponseCookie cookie = ResponseCookie.from("token", "")
.httpOnly(true)
.maxAge(0)
.sameSite(request.isSecure() ? "None" : "Lax")
.secure(request.isSecure())
.path("/")
.build();
/**
* Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies.
*
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the new cookies are written to
* @return {@code 200 OK} wrapping the new access token lifetime as a {@link SessionResponse}
* @throws NotAuthenticated if the refresh token is missing or rejected
*/
@PostMapping("/refresh")
public ResponseEntity<APIResponse<SessionResponse>> refresh(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletRequest request,
HttpServletResponse response) {
TokenPackage tokenPackage = userService.refresh(refreshToken);
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Refreshed tokens successfully", sessionResponse())
);
}
/**
* Logs the user out by revoking the session tied to the {@code refreshToken} cookie and clearing
* both auth cookies. Succeeds even if no refresh token cookie is present.
*
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the expired cookies are written to
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletRequest request,
HttpServletResponse response) {
userService.logout(refreshToken);
writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged out successfully", null)
);
}
private SessionResponse sessionResponse() {
return new SessionResponse(jwtExpiration.getSeconds());
}
private void addCookies(
HttpServletRequest request,
HttpServletResponse response,
String accessToken,
String refreshToken) {
writeCookies(request, response, accessToken, jwtExpiration, refreshToken, refreshTokenExpiration);
}
private void writeCookies(
HttpServletRequest request,
HttpServletResponse response,
String accessToken,
Duration accessTokenMaxAge,
String refreshToken,
Duration refreshTokenMaxAge) {
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
.httpOnly(true)
.maxAge(accessTokenMaxAge)
.sameSite("Lax")
.secure(request.isSecure())
.path(ACCESS_COOKIE_PATH)
.build();
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.maxAge(refreshTokenMaxAge)
.sameSite("Lax")
.secure(request.isSecure())
.path(REFRESH_COOKIE_PATH)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
}
}

View File

@@ -6,14 +6,12 @@ import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.services.VodService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/v1/vods")
@@ -26,6 +24,11 @@ public class VodController {
this.vodService = vodService;
}
/**
* Lists all VoDs belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's VoDs as {@link VodResponse} DTOs
*/
@GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream()
@@ -35,12 +38,25 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods));
}
/**
* Retrieves a single VoD owned by the authenticated user.
*
* @param id the ID of the VoD to retrieve
* @return {@code 200 OK} wrapping the VoD as a {@link VodResponse} DTO
*/
@GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod));
}
/**
* Applies a partial update to a VoD's metadata; null fields are left unchanged.
*
* @param id the ID of the VoD to update
* @param updateFields the title and/or description to overwrite
* @return {@code 200 OK} wrapping the updated VoD as a {@link VodResponse} DTO
*/
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) {
@@ -48,6 +64,12 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod));
}
/**
* Deletes a VoD and its associated files.
*
* @param id the ID of the VoD to delete
* @return {@code 200 OK} with a confirmation message
*/
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id);
@@ -55,6 +77,13 @@ public class VodController {
"Vod with ID " + id + " has been deleted"));
}
/**
* Streams the VoD's video file inline, supporting HTTP range requests.
*
* @param id the ID of the VoD to download
* @return {@code 200 OK} with the video resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's content length cannot be read
*/
@GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id);
@@ -72,8 +101,15 @@ public class VodController {
.body(resource);
}
/**
* Streams the VoD's thumbnail image with a public cache policy.
*
* @param id the ID of the VoD whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
@@ -81,6 +117,8 @@ public class VodController {
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
@@ -93,11 +131,7 @@ public class VodController {
vod.getTitle(),
vod.getDescription(),
vod.getDuration(),
vod.getCreatedAt(),
vod.getWidth(),
vod.getHeight(),
vod.getFps(),
vod.getFileSize()
vod.getCreatedAt()
);
}
}

View File

@@ -8,9 +8,5 @@ public record ClipResponse(
String title,
String description,
Float duration,
Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
Instant createdAt
) {}

View File

@@ -0,0 +1,11 @@
package com.ddf.vodsystem.controllers.dto;
/**
* Session metadata returned after login or refresh. The tokens themselves are only ever sent as
* HttpOnly cookies; this tells the client when to refresh them.
*
* @param accessTokenExpiresIn seconds until the access token cookie expires
*/
public record SessionResponse(
long accessTokenExpiresIn
) {}

View File

@@ -1,4 +0,0 @@
package com.ddf.vodsystem.controllers.dto;
/** Where OBS (or anything else speaking RTMP) should publish to. */
public record StreamMetadataResponse(String url, Float streamMaxLength) {}

View File

@@ -1,10 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record StreamResponse(
boolean isStreaming,
Long id,
Instant startTime
Long id
) {
}

View File

@@ -8,9 +8,5 @@ public record VodResponse(
String title,
String description,
Float duration,
Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
Instant createdAt
) {}

View File

@@ -11,7 +11,7 @@ public class ClipOptions {
private String description;
@NotNull
@Positive
@PositiveOrZero
private Float startPoint;
@NotNull @Positive

View File

@@ -10,6 +10,11 @@ public class CommandOutput {
private List<String> output = new ArrayList<>();
private int exitCode;
/**
* Appends a line to the captured command output.
*
* @param line the output line to append
*/
public void addLine(String line) {
output.add(line);
}

View File

@@ -0,0 +1,9 @@
package com.ddf.vodsystem.dto;
public record GoogleUser(
String googleId,
String email,
String name,
String profilePictureUrl
) {
}

View File

@@ -4,14 +4,30 @@ public class ProgressTracker {
private float progress = 0.0f;
private boolean isComplete = false;
/**
* Returns the current progress.
*
* @return the progress as a fraction in {@code [0, 1]}
*/
public synchronized float getProgress() {
return progress;
}
/**
* Returns whether the tracked task has been marked complete.
*
* @return {@code true} if complete
*/
public synchronized boolean isComplete() {
return isComplete;
}
/**
* Updates the current progress.
*
* @param newProgress the progress as a fraction in {@code [0, 1]}
* @throws IllegalArgumentException if {@code newProgress} is outside {@code [0, 1]}
*/
public synchronized void setProgress(float newProgress) {
if (newProgress < 0 || newProgress > 1) {
throw new IllegalArgumentException("Progress must be between 0 and 1");
@@ -19,10 +35,16 @@ public class ProgressTracker {
this.progress = newProgress;
}
/**
* Marks the tracked task as complete.
*/
public synchronized void markComplete() {
this.isComplete = true;
}
/**
* Resets progress to zero and clears the complete flag.
*/
public synchronized void reset() {
this.progress = 0.0f;
this.isComplete = false;

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.dto;
public record TokenPackage (
String refreshToken,
String accessToken
) {
}

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