18 Commits

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

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

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

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

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

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

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

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

View File

@@ -18,4 +18,3 @@ FRONTEND_URL=
BACKEND_URL= BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800 STREAM_RING_LENGTH_SECOND=28800
STREAM_INGEST_URL=

View File

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

View File

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

View File

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

View File

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

View File

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

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"> <link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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> <title>The VoD System</title>
</head> </head>
<body> <body>

View File

@@ -7,14 +7,13 @@
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {
@@ -915,68 +914,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1043,12 +980,6 @@
"url": "https://github.com/sponsors/nzakas" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1099,41 +1030,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@react-oauth/google": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", "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": { "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2359,9 +2255,9 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2413,81 +2309,6 @@
"node": ">=10" "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": { "node_modules/@typescript-eslint/utils": {
"version": "8.65.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", "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": { "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2637,23 +2458,6 @@
"node": ">=10" "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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2738,28 +2542,11 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
@@ -2786,18 +2573,6 @@
"concat-map": "0.0.1" "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": { "node_modules/browserslist": {
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
@@ -2880,27 +2655,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2930,22 +2684,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2973,11 +2711,16 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-util-is": { "node_modules/core-js": {
"version": "1.0.3", "version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"license": "MIT" "hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
@@ -3001,22 +2744,11 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3037,15 +2769,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3246,6 +2969,7 @@
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3272,19 +2996,6 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/esquery": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -3338,34 +3049,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -3380,15 +3063,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3402,18 +3076,6 @@
"node": ">=16.0.0" "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": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3452,36 +3114,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3550,15 +3182,6 @@
"node": ">=8" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3600,6 +3223,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -3609,6 +3233,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -3617,15 +3242,6 @@
"node": ">=0.10.0" "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": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3651,9 +3267,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3730,15 +3346,6 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4078,28 +3685,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -4117,12 +3702,13 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4205,12 +3791,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4251,18 +3831,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -4312,26 +3880,6 @@
"node": ">=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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -4353,6 +3901,29 @@
"react": "^19.2.8" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4364,9 +3935,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.1", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -4386,12 +3957,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.1", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.1" "react-router": "7.18.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -4401,44 +3972,6 @@
"react-dom": ">=18" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -4449,16 +3982,6 @@
"node": ">=4" "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": { "node_modules/rollup": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -4504,29 +4027,6 @@
"fsevents": "~2.3.2" "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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4572,15 +4072,6 @@
"node": ">=8" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4617,38 +4108,11 @@
"node": ">=8" "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": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
@@ -4665,12 +4129,6 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -4719,22 +4177,11 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.12" "node": ">=18.12"
@@ -4747,7 +4194,9 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "dev": true,
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -4766,6 +4215,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -4870,9 +4320,9 @@
} }
}, },
"node_modules/typescript-eslint/node_modules/brace-expansion": { "node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -8,16 +8,15 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview"
"postinstall": "flowbite-react patch"
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -15,6 +15,11 @@ import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx"; import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
import ThemeProvider from "./theme/ThemeProvider.tsx";
import RequireAuth from "./auth/RequireAuth.tsx";
function App() { function App() {
@@ -23,24 +28,33 @@ function App() {
}, []); }, []);
return ( return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}> <GoogleOAuthProvider clientId={config.googleClientId}>
<Router> <ThemeProvider>
<Routes> <AuthProvider>
<Route element={<MainLayout />}> <Router>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/create" element={<ClipUpload />} /> <Route element={<MainLayout />}>
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/" element={<Home />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/my-vods" element={<MyVods />} /> <Route path="/create/:id" element={<ClipEdit />} />
<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 element={<RequireAuth />}>
<Route path="/clips/:id/edit" element={<EditClip />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/vods/:id/edit" element={<EditVod />} /> <Route path="/my-vods" element={<MyVods />} />
<Route path="/profile" element={<Profile />} /> <Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/stream" element={<StreamControl />} /> <Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
</Route> <Route path="/clips/:id/edit" element={<EditClip />} />
</Routes> <Route path="/vods/:id/edit" element={<EditVod />} />
</Router> <Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
</ GoogleOAuthProvider> </ 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. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
import {registerUnauthorizedHandler} from "../utils/api/client.ts";
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
} catch (error) {
console.error("Failed to fetch user:", error);
setUser(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
await refresh();
}, [refresh]);
const logout = useCallback(async () => {
await apiLogout();
await refresh();
}, [refresh]);
useEffect(() => {
registerUnauthorizedHandler(() => setUser(null));
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;

View File

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

View File

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

View File

@@ -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) => ( const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li <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)} onClick={() => onClick(item)}
> >
{item} {item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}> <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button <button
onClick={() => setIsOpen(v => !v)} 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} {label}
</button> </button>
{isOpen && ( {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} {children}
</ul> </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: string;
}; };
// Label on the left, control on the right — the shape used for export settings.
const Selector = ({ children, label }: Props) => ( const Selector = ({ children, label }: Props) => (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center gap-2">
<label className="font-body text-[13px] text-text-body">{label}</label> <label className="w-full text-sm text-text-secondary">{label}</label>
<div className="w-32 flex-shrink-0">{children}</div> <div className="w-px h-5 text-text-secondary mx-2" />
{children}
</div> </div>
); );

View File

@@ -1,9 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, Video, Radio } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import {useAuth} from "../utils/AuthContext.tsx"; import { useAuth } from "../auth/useAuth.ts";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
type props = { type props = {
className?: string className?: string
@@ -11,78 +9,41 @@ type props = {
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />; const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({ className }: props) => { const Sidebar = ({className}: props) => {
const { user, setUser } = useAuth() const { user } = useAuth();
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
return ( return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}> <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 /> <Divider />
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" /> <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> </div>
<Divider /> <Divider />
{user && ( {user && (
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<div> <div>
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" /> <SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" /> <SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</div> </div>
</div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<div className={"p-2"}> <div className={"w-full"}>
{user ? ( {user && (
<div> <>
<Divider /> <Divider />
<div className={"flex gap-2 items-center pt-1"}> <SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
<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>
)} )}
</div> </div>
</nav> </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

@@ -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 type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = { type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
// Mirrors CompressionService.buildBitrate so the number shown is the number used: 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";
// 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;
type Estimate = { total: number; audio: number; video: number; bpp: number | null }; export default function ConfigBox({ setMetadata, className }: Props) {
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) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number); const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height })); setMetadata(prev => ({ ...prev, width, height }));
@@ -55,19 +21,15 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
}; };
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { 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 ( return (
<div className={clsx("flex flex-col gap-4", className)}> <div className={clsx("flex flex-col gap-3 p-6", className)}>
<p className={kickerClass}>Export settings</p> <h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
<Selector label="Resolution"> <Selector label="Resolution">
<select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`} <select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}> onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option> <option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option> <option value="2560,1440">1440p</option>
@@ -78,8 +40,8 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="Frames per second"> <Selector label="FPS">
<select id="fps" name="fps" value={String(metadata.fps)} <select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}> onChange={updateFps} className={inputClass}>
<option value="60">60</option> <option value="60">60</option>
<option value="30">30</option> <option value="30">30</option>
@@ -87,35 +49,11 @@ export default function ConfigBox({ metadata, setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="Size limit (MB)"> <Selector label="File Size Limit (MB)">
<input type="number" min="1" value={sizeMb} <input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={monoInputClass} onChange={updateFileSize} className={inputClass}
/> />
</Selector> </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> </div>
); );
} }

View File

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

View File

@@ -1,7 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = { type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -9,14 +8,16 @@ type Props = {
}; };
const MetadataBox = ({ setMetadata, className }: Props) => ( const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col gap-2", className)}> <div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<label className={labelClass} htmlFor="clip-title">Title</label> <p className="w-full font-body text-text-strong font-bold text-sm">
Title <span className="text-error">*</span>
</p>
<input <input
id="clip-title"
type="text" type="text"
placeholder="Cat walks across the patch bay" required
placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))} 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> </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]); }, [video]);
return ( return (
<div className={clsx("flex justify-between items-center mb-3", className)}> <div className={clsx("flex justify-between items-center mb-2", className)}>
<div className="flex items-center gap-2 text-text-control"> <div className="flex gap-1">
<Icon size={20} /> <Icon size={24} />
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={volume} value={volume}
onChange={updateVolume} onChange={updateVolume}
className="w-20 accent-accent" className="w-20"
/> />
</div> </div>
<button <button
onClick={togglePlay} 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> </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)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span> </span>
</div> </div>

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 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 { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void, onEdit?: () => void,
onDelete?: () => void, onDelete?: () => void,
className?: string, className?: string,
@@ -23,42 +18,23 @@ type VideoCardProps = {
itemLabel?: string, 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 VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const placeholders: Record<string, React.CSSProperties> = { const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" }, const [imgFailed, setImgFailed] = useState(false);
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 [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`; 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(() => { useEffect(() => {
isThumbnailAvailable(thumbnailUrl) const interval = setInterval(() => {
.then(setThumbnailAvailable) setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
.catch(() => setThumbnailAvailable(false)); }, 1000);
}, [thumbnailUrl]); return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
@@ -80,28 +56,16 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
return ( return (
<Link to={`${playerPath}/${id}`}> <Link to={`${playerPath}/${id}`}>
<div className={clsx( <div className={clsx("flex flex-col group cursor-pointer", className)}>
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200", <div className="relative overflow-hidden rounded-lg">
className <img
)}> src={imgFailed ? fallbackThumbnail : thumbnailUrl}
<div onError={() => setImgFailed(true)}
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")} alt="Video Thumbnail"
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']} className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
> />
{thumbnailAvailable && (
<img
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<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"> <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">
{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">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
@@ -113,18 +77,18 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
> >
<button <button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })} 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} /> <MoreVertical size={14} />
</button> </button>
{menuOpen && ( {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 && (<> {!confirmDelete && (<>
{onEdit && ( {onEdit && (
<button <button
onClick={(e) => stopAndRun(e, onEdit)} onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 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 <Pencil size={13} /> Edit
</button> </button>
@@ -132,7 +96,7 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
{onDelete && ( {onDelete && (
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 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 <Trash2 size={13} /> Delete
</button> </button>
@@ -140,17 +104,17 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <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"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} 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 Delete
</button> </button>
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))} 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 Cancel
</button> </button>
@@ -163,28 +127,11 @@ const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSiz
)} )}
</div> </div>
<div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}> <div className="flex flex-col p-2">
<p className={clsx( <p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
"line-clamp-2", {title === "" ? "(No Title)" : title}
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
</p> </p>
<p className="text-muted text-xs mt-0.5">{timeAgo}</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>
)}
</div> </div>
</div> </div>
</Link> </Link>

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

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

View File

@@ -1,5 +1,9 @@
@import "tailwindcss"; @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 { @theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */ /* ── Fonts ───────────────────────────────────────────────────────────── */
@@ -11,80 +15,91 @@
--color-sidebar: #F2EEE6; --color-sidebar: #F2EEE6;
--color-card: #FDFBF7; --color-card: #FDFBF7;
--color-fields: #F4F0E8; --color-fields: #F4F0E8;
--color-panel: #F1ECE3;
--color-dropzone: #F9F6EF; --color-dropzone: #F9F6EF;
--color-armed: #F6EFE6;
--color-live-card: #F0EBE1; /* Letterbox behind video. Dark in both themes — it is a cinema surface,
--color-player: #2B2620; not an ink colour — so `text-on-dark` always reads on top of it. */
--color-player-stripe: #3A342C; --color-media-backdrop: #2B2620;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620; --color-text-primary: #2B2620;
--color-text-strong: #35302A; --color-text-strong: #35302A;
--color-text-body: #554D44; --color-text-body: #554D44;
--color-text-control: #4A423A;
--color-text-secondary: #6E6559; --color-text-secondary: #6E6559;
--color-text-table: #7A7166;
--color-muted: #9A9084; --color-muted: #9A9084;
--color-faint-mono: #A79C8E; --color-faint-mono: #A79C8E;
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Accents ─────────────────────────────────────────────────────────── */ /* ── Accents ─────────────────────────────────────────────────────────── */
--color-accent: #A85A38; --color-terracotta: #A85A38;
--color-accent-hover: #94492A; --color-terracotta-hover: #94492A;
--color-accent-light: #C87A52; --color-terracotta-light: #C87A52;
--color-accent-disabled: #C9AE9C; --color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50; --color-olive: #6E7A50;
--color-olive-hover: #5D6843;
--color-olive-light: #8A9668; /* ── Status ──────────────────────────────────────────────────────────── */
--color-live-red: #B5432E; --color-live: #B5432E;
--color-error: #B5432E;
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2; --color-hairline: #F0EBE2;
--color-hover: #E7E0D2; --color-hover: #E7E0D2;
--color-chip: #EAE3D7; --color-selected: #F6EFE6;
--color-chip-hover: #DAD1C0; --color-ring: #E2DACB;
--color-ring-dashed: #E2DACB;
--color-inactive: #DED7CA; --color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9; --color-inactive-strong: #C4BAA9;
}
/* ── Elevation — soft shadows instead of borders ─────────────────────── */ /* Dark mode. Only token values change here: every component already styles
--shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35); itself with the semantic utilities above, so the whole UI follows from this
--shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45); one block. `--color-on-dark` is deliberately absent — it is the ink on the
--shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3); media backdrop, which stays dark in both themes. */
--shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5); :root[data-theme="dark"] {
--shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55); color-scheme: dark;
--shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55);
--inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08); /* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
--inset-shadow-armed: inset 0 0 0 1.5px #A85A38; --color-background: #0E0E10;
--inset-shadow-focus: inset 0 0 0 1.5px #C87A52; --color-sidebar: #09090B;
--inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB; --color-card: #17171A;
--color-fields: #121215;
--color-dropzone: #141417;
/* ── Shape ───────────────────────────────────────────────────────────── */ --color-media-backdrop: #000000;
--radius-hero: 16px;
--radius-panel: 15px;
--radius-tile: 13px;
--radius-thumb: 12px;
--radius-field: 10px;
--radius-btn: 9px;
--radius-chip: 8px;
/* ── Motion — the live dot, plus the player's one-shot action badge ──── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--animate-live-pulse: livePulse 2.2s ease-in-out infinite; --color-text-primary: #F4F4F5;
--animate-flash-badge: flashBadge 500ms ease-out forwards; --color-text-strong: #E3E3E7;
--color-text-body: #BFBFC6;
--color-text-secondary: #94949D;
--color-muted: #76767F;
--color-faint-mono: #6A6A73;
@keyframes livePulse { /* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
0%, 100% { opacity: 1; transform: scale(1); } rather than deepens ─────────────────────────────────────────────────── */
50% { opacity: .45; transform: scale(1.35); } --color-terracotta: #E07A4C;
} --color-terracotta-hover: #F08F62;
--color-terracotta-light: #F0A177;
--color-terracotta-disabled: #5E463A;
--color-olive: #A3B678;
@keyframes flashBadge { /* ── Status ──────────────────────────────────────────────────────────── */
0% { opacity: 0; transform: scale(.75); } --color-live: #F2604A;
25% { opacity: 1; transform: scale(1); } --color-error: #F2604A;
100% { opacity: 0; transform: scale(1.4); }
} /* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #17100B;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #232327;
--color-hover: #26262B;
--color-selected: #2A1F18;
--color-ring: #33333A;
--color-inactive: #2C2C32;
--color-inactive-strong: #55555E;
} }
:root { :root {
@@ -95,6 +110,7 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color-scheme: light;
} }
body { body {
@@ -106,5 +122,38 @@ body {
@utility font-heading { @utility font-heading {
font-family: "Newsreader", serif; font-family: "Newsreader", serif;
font-weight: 400; 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,16 +1,34 @@
// layout/MainLayout.jsx // layout/MainLayout.jsx
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; 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 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 ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<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 /> <Outlet />
</div> </div>
</div> </div>

View File

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

View File

@@ -1,19 +1,15 @@
import { useLocation } from 'react-router-dom'; 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 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 ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media" 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 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 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 ClipEdit = () => {
const location = useLocation(); const location = useLocation();
@@ -25,7 +21,6 @@ const ClipEdit = () => {
const [metadata, setMetadata] = useState<VideoMetadata | null>(null); const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [range, setRange] = useState<Range>([0, 5]);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
title: "", title: "",
description: "", description: "",
@@ -61,39 +56,23 @@ const ClipEdit = () => {
fps: 30, fps: 30,
fileSize: localFile?.size ?? 0, 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 () => { const sendData = async () => {
if (!localFile || uploading) return; if (!localFile || uploading) return;
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
setUploading(true); setUploading(true);
setError(null); setError(null);
let jobId: string; let jobId: string;
try { try {
jobId = await compress(localFile, outputMetadata); jobId = await compress(localFile, { ...outputMetadata, title });
setUploadedId(jobId); setUploadedId(jobId);
} catch (err: unknown) { } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -129,14 +108,14 @@ const ClipEdit = () => {
const handleDownload = async () => { const handleDownload = async () => {
if (!uploadedId) return; if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' }); let blob: Blob;
try {
if (!response.ok) { blob = await downloadJob(uploadedId);
console.error('Download failed'); } catch (err) {
console.error('Download failed', err);
return; return;
} }
const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -149,78 +128,60 @@ const ClipEdit = () => {
}; };
return ( return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18"> <div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<PageHeader kicker="Upload" title="Trim and compress" /> <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"> <Box className={"w-4/5 h-full m-auto"}>
<div className="flex flex-col gap-[26px]"> <MetadataBox setMetadata={setOutputMetadata} />
{/* Player — the video and its transport read as one object. */} <ConfigBox setMetadata={setOutputMetadata} />
<Card className="overflow-hidden"> </Box>
<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>
{metadata && {metadata &&
<Card className="p-6"> <Box className={"mt-4 p-5"}>
<div className="flex items-baseline justify-between gap-4 pb-4"> <Playbar
<p className={kickerClass}>Trim</p> video={videoRef.current}
<p className="font-data text-[12px] text-faint-mono tabular-nums"> videoMetadata={metadata}
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "} className={"w-full accent-terracotta"}
{formatTime(range[1] - range[0])} long />
</p>
</div>
<TrimTimeline <PlaybackSlider
duration={metadata.duration} videoRef={videoRef.current}
range={range} videoMetadata={metadata}
onRangeChange={handleRangeChange} sliderValue={playbackValue}
currentTime={playbackValue} setSliderValue={setPlaybackValue}
onSeek={handleSeek} className={"w-full accent-terracotta"}
/> />
<p className="pt-3 font-body text-[12px] text-muted"> <ClipRangeSlider
Scroll over the timeline to zoom, drag the outlined window above to pan. videoRef={videoRef.current}
</p> videoMetadata={metadata}
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2"}
/>
{error && ( {error && (
<p className="font-body text-[13px] text-live-red pt-3">{error}</p> <div className={"text-error text-center mt-2"}>
)} {error}
</Card> </div>
} )}
</div> </Box>
}
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10"> <Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
<Card className="p-6 flex flex-col gap-4"> <ExportWidget
<p className={kickerClass}>Details</p> dataSend={sendData}
<MetadataBox setMetadata={setOutputMetadata} /> handleDownload={handleDownload}
</Card> downloadable={downloadable}
progress={progress}
<Card className="p-6"> uploading={uploading}
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} /> />
</Card> </Box>
<div className="bg-panel rounded-panel shadow-panel p-6">
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</div>
</aside>
</div>
</div> </div>
); );
}; };

View File

@@ -1,89 +1,56 @@
import { useRef, useState } from "react"; import {useState} from "react";
import { useNavigate } from "react-router-dom"; import {useNavigate} from "react-router-dom";
import clsx from "clsx"; import Box from "../components/Box.tsx";
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`;
};
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const choose = (selected: File | null) => {
setFile(selected);
setError(null);
};
const press = () => { const press = () => {
if (!file) { if (!file) {
setError("Pick a file first."); setError("Please choose a file");
return; return;
} }
navigate('/create/new', { state: { file } }); navigate('/create/new', { state: { file } });
}; };
return ( return (
<div className="mx-auto max-w-180 px-13 pt-10 pb-18"> <div className="flex justify-center p-8">
<PageHeader kicker="Upload" title="Bring in a file" /> <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 <button
onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onClick={press}
onDragLeave={() => setDragging(false)} className={"hover:bg-hover p-1 rounded-md"}
onDrop={(e) => { >
e.preventDefault(); Upload
setDragging(false); </button>
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)}
/>
{file ? ( {error && <p className="text-sm font-body text-center">{error}</p>}
<> </Box>
<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>}
</div> </div>
) )
}; };

View File

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

View File

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

View File

@@ -1,311 +1,78 @@
import {useAuth} from "../utils/AuthContext.tsx"; import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
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";
type TimeOfDay = "morning" | "afternoon" | "evening" | "night"; const features = [
{
function getTimeOfDay(date: Date = new Date()): TimeOfDay { icon: Upload,
const hour = date.getHours(); title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
if (hour >= 5 && hour < 12) return "morning"; },
if (hour >= 12 && hour < 17) return "afternoon"; {
if (hour >= 17 && hour < 22) return "evening"; icon: Scissors,
return "night"; title: "Trim",
} description: "Set start and end points with a range slider. Preview as you go.",
},
function getGreetingSubtext(date: Date = new Date()): string { {
const timeOfDay = getTimeOfDay(date); icon: SlidersHorizontal,
title: "Export settings",
const messages: Record<TimeOfDay, string[]> = { description: "Choose resolution, frame rate, and an optional file size cap.",
morning: [ },
"Good morning.", {
"Coffee first, then clips.", icon: Radio,
"Here's what's waiting for you.", title: "RTMP streaming",
], description: "Point OBS at your stream key and go live. Find the key in your profile.",
afternoon: [ },
"Good afternoon.", {
"A quiet moment for your clips.", icon: LayoutGrid,
"Catching up?", title: "Clip library",
], description: "Browse all your clips. Watch, rename, edit the description, or delete.",
evening: [ },
"Good evening.", {
"Settle in.", icon: Play,
"Time to unwind.", title: "Built-in player",
], description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
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 Home = () => { 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 ( return (
<div className={"w-10/12 m-auto pt-10"}> <div className="flex flex-col items-center px-8 py-12 min-h-full">
<div className={"flex flex-row justify-between items-start pb-8"}> {/* Hero */}
<div> <div className="flex flex-col items-center text-center max-w-2xl mb-14">
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p> <img
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1> src="/logo.png"
</div> alt="VoD System Logo"
className="h-24 mb-6 object-contain"
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}> />
Open Stream Panel <h1 className="text-4xl font-heading text-text-primary">
</Link> 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> </div>
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}> {/* Feature grid */}
<div className={"flex flex-row items-center gap-2"}> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{isStreaming {features.map(({ icon: Icon, title, description }) => (
? <IsLivePulse /> <div
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />} key={title}
<p className={clsx( className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
"text-xs font-data tracking-[0.12em]", >
isStreaming ? "text-live-red" : "text-faint-mono" <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
)}> <Icon size={18} className="text-terracotta" />
{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>
</div> </div>
<div>
<div className={"mt-4 flex items-center gap-4"}> <p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button> <p className="text-sm text-text-secondary leading-relaxed">{description}</p>
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
</div> </div>
</> </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> </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 { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react"; import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import clsx from "clsx";
import { AuthError } from "../utils/api/client.ts"; import { AuthError } from "../utils/api/client.ts";
import Card from "../components/Card.tsx"; import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
import { kickerClass, quietLinkClass } from "../utils/styles.ts";
// Both VoDs and clips share the same on-screen shape. // Both VoDs and clips share the same on-screen shape.
type MediaItem = { type MediaItem = {
title: string, title: string,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
}; };
type MediaPlayerProps = { type MediaPlayerProps = {
@@ -60,15 +53,12 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false); 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 ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
// which lets the browser fetch it incrementally via Range requests. We only
// need to fetch the metadata here; it shares the media endpoint's auth, so a
// 401/403 here also stands in for the stream being inaccessible.
useEffect(() => { useEffect(() => {
if (!id) { setError(`${noun} ID is required.`); return; } if (!id) { setError(`${noun} ID is required.`); return; }
@@ -78,7 +68,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (e instanceof AuthError) setNotAuthenticated(true); if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`); else setError(`Failed to load ${noun.toLowerCase()} details.`);
}); });
}, [id, noun, fetchDetails]); }, [id, noun, fetchDetails]);
useEffect(() => { useEffect(() => {
@@ -100,6 +89,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide(); scheduleHide();
}, [scheduleHide]); }, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
@@ -129,13 +119,11 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5); video.currentTime = Math.max(0, video.currentTime - 5);
showFlash('back');
showControls(); showControls();
break; break;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5); video.currentTime = Math.min(video.duration, video.currentTime + 5);
showFlash('forward');
showControls(); showControls();
break; break;
case 'KeyM': case 'KeyM':
@@ -149,15 +137,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [showControls, showFlash, toggleFullscreen]); }, [showControls, 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]);
// ── Progress bar seeking ──────────────────────────────────────────────── // ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => { const seekTo = useCallback((clientX: number) => {
@@ -211,40 +191,29 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const togglePlay = () => { const togglePlay = () => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
showFlash(video.paused ? 'play' : 'pause');
video.paused ? video.play() : video.pause(); video.paused ? video.play() : video.pause();
showControls(); showControls();
}; };
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume; const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2; 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) { if (notAuthenticated) {
return ( return (
<div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8"> <div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} /> <div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2> <Lock size={24} className="text-muted" />
<p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty"> </div>
You don't have access to this {noun.toLowerCase()}. <div>
</p> <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 <button
onClick={() => navigate(-1)} 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 Go back
</button> </button>
</div> </div>
@@ -252,19 +221,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
} }
return ( 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 <button
onClick={() => navigate(-1)} 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 Back
</button> </button>
{/* Player */} {/* Player */}
<div <div
ref={containerRef} 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} onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)} onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay} onClick={togglePlay}
@@ -296,37 +265,14 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Buffering spinner */} {/* Buffering spinner */}
{isWaiting && mediaUrl && ( {isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" /> <div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
</div>
)}
{/* 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> </div>
)} )}
{/* Controls overlay — pointer-events-none so video clicks pass through */} {/* 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'}`}> <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 */} {/* Gradient */}
<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-gradient-to-t from-black/75 via-black/10 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>
{/* Controls bar — pointer-events-auto only here */} {/* Controls bar — pointer-events-auto only here */}
<div <div
@@ -336,42 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Scrub bar */} {/* Scrub bar */}
<div <div
ref={progressRef} ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150" className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }} onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
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}%` }} /> <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}%` }} />
{/* 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 <div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity" className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }} style={{ left: `calc(${progressPct}% - 6px)` }}
/> />
{/* 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> </div>
{/* Buttons row */} {/* Buttons row */}
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-on-dark">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors"> <button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors">
{isPlaying {isPlaying
? <Pause size={20} fill="currentColor" /> ? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />} : <Play size={20} fill="currentColor" />}
@@ -379,7 +303,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Volume — slider expands on hover */} {/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol"> <div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors"> <button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
<VolumeIcon size={18} /> <VolumeIcon size={18} />
</button> </button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200"> <div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
@@ -388,18 +312,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
min={0} max={1} step={0.02} min={0} max={1} step={0.02}
value={effectiveVolume} value={effectiveVolume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer" className="w-16 accent-on-dark cursor-pointer"
/> />
</div> </div>
</div> </div>
<span className="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)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors"> <button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />} {isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
@@ -407,26 +331,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</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 && ( {!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"> <Box className="p-4 flex-shrink-0">
<div className="min-w-0"> <p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
<p className={kickerClass}>{noun}</p> <p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
<p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1"> </Box>
{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>
</div> </div>
); );
}; };

View File

@@ -1,24 +1,19 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; 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 { getClips, deleteClip } from "../utils/api/clips";
import { useAuth } from "../utils/AuthContext";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const { user, loading } = useAuth();
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .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")); .catch((err) => setError(err));
}, [user]); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -29,16 +24,11 @@ const MyClips = () => {
} }
}; };
if (loading) return null;
if (!user) return <NoAccessPage />;
return ( return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18"> <div className="p-6">
<PageHeader kicker="Library" title="Clips"> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
</PageHeader>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[18px]"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
@@ -46,10 +36,6 @@ const MyClips = () => {
title={clip.title} title={clip.title}
duration={clip.duration} duration={clip.duration}
createdAt={clip.createdAt} createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
@@ -57,17 +43,13 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<h2 className="font-heading text-[25px]">No clips yet.</h2> <p className="text-base">No clips yet.</p>
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty"> <p className="text-sm mt-1">Upload your first clip to get started.</p>
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>
</div> </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> </div>
); );
} }

View File

@@ -1,24 +1,19 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { useAuth } from "../utils/AuthContext";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const { user, loading } = useAuth();
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .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")); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, [user]); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -29,16 +24,11 @@ const MyVods = () => {
} }
}; };
if (loading) return null;
if (!user) return <NoAccessPage />;
return ( return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18"> <div className="p-6">
<PageHeader kicker="Library" title="VoDs"> <h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
</PageHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => ( {vods.map((vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
@@ -46,10 +36,6 @@ const MyVods = () => {
title={vod.title} title={vod.title}
duration={vod.duration} duration={vod.duration}
createdAt={vod.createdAt} createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods" mediaApiPath="/api/v1/vods"
playerPath="/vod" playerPath="/vod"
itemLabel="vod" itemLabel="vod"
@@ -60,15 +46,13 @@ const MyVods = () => {
</div> </div>
{vods.length === 0 && !error && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2> <p className="text-base">No VoDs yet.</p>
<p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty"> <p className="text-sm mt-1">Your stream recordings will appear here.</p>
Ranges you save from the stream panel land here, full length and untouched.
</p>
</div> </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> </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 ( return (
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6"> <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} /> <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"> <h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
Log in to see this page This page does not exist.
</h2> </h2>
<p className="text-sm text-faint-mono/70 max-w-xs"> <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> </p>
</div> </div>
); );
} }
export default NoAccessPage; export default NotFound;

View File

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

View File

@@ -1,72 +1,80 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { Check, X } from "lucide-react"; import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Box from "../components/Box.tsx";
import Card from "../components/Card.tsx"; import CardHeader from "../components/CardHeader.tsx";
import Button from "../components/buttons/Button.tsx"; import MarkerList from "../components/stream/MarkerList.tsx";
import PageHeader from "../components/PageHeader.tsx"; import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import IsLivePulse from "../components/IsLivePulse.tsx"; import { useAuth } from "../auth/useAuth.ts";
import NoAccessPage from "../components/NoAccessPage.tsx"; import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.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 { getJob } from "../utils/api/jobs.ts";
import type { Marker, StreamStatus } from "../utils/types.ts"; import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts"; import { formatLocalDate, stringToDate } from "../utils/utils.ts";
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
/** Ten-minute gridlines behind the marker ticks. */ 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 GRID_INTERVAL_SECONDS = 600; const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
const useCopy = () => { type TrackedJob = {
const [copied, setCopied] = useState<string | null>(null); uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const copy = (key: string, text: string) => { const JobStatus = ({ job }: { job: TrackedJob }) => {
navigator.clipboard.writeText(text); if (job.state === 'FAILED') {
setCopied(key); return (
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600); <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>
return { copied, copy }; </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 StreamControl = () => {
const { user, loading } = useAuth(); const { user } = useAuth();
const { copied, copy } = useCopy();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null); 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 [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null); 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 [clipDuration, setClipDuration] = useState(30);
const [clipName, setClipName] = useState(""); const [clipTitle, setClipTitle] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipError, setClipError] = useState<string | null>(null); const [clipError, setClipError] = useState<string | null>(null);
const [keyVisible, setKeyVisible] = useState(false); const [startMarkerId, setStartMarkerId] = useState<string>("");
const [ingestUrl, setIngestUrl] = useState<string | null>(null); 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 [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false; 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(() => { useEffect(() => {
if (!user) return; if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -76,26 +84,25 @@ const StreamControl = () => {
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 1000); if (!user || !streamStatus?.id) {
return () => clearInterval(interval); setStreamDetails(null);
}, []); return;
}
useEffect(() => { getStreamHistory(user.id)
if (!user) return; .then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null)); .catch(() => {});
}, [user]); }, [user, streamStatus?.id]);
const refreshMarkers = () => { const refreshMarkers = () => {
getMarkers() getMarkers()
.then((data) => setMarkers([...data] .then((data) => setMarkers(data
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime()))) .sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
.catch(() => {}); .catch(() => {});
}; };
useEffect(() => { useEffect(() => {
if (!isStreaming) { if (!isStreaming) {
setMarkers([]); setMarkers([]);
setSelected([]);
return; return;
} }
refreshMarkers(); refreshMarkers();
@@ -103,495 +110,212 @@ const StreamControl = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]); }, [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(() => { useEffect(() => {
if (!pendingKey) return; const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
const uuids = pendingKey.split(","); if (pending.length === 0) return;
const interval = setInterval(() => { const interval = setInterval(() => {
uuids.forEach((uuid) => { pending.forEach((job) => {
getJob(uuid) getJob(job.uuid)
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid .then((result) => {
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput } setJobs((prev) => prev.map((j) => j.uuid === job.uuid
: job))) ? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
: j));
})
.catch(() => {}); .catch(() => {});
}); });
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [pendingKey]); }, [jobs]);
// ── Marker helpers ────────────────────────────────────────────────────── const addJob = (uuid: string, label: string) => {
const offsetOf = (marker: Marker) => setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
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]));
}; };
// 'M' anywhere on the page jumps to the marker field. const handleAddMarker = async (message: string) => {
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);
setMarkerError(null); setMarkerError(null);
try { try {
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`); await createMarker(message);
setMarkerMessage("");
refreshMarkers(); refreshMarkers();
return true;
} catch (err) { } catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker"); setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
} finally { return false;
setMarkerBusy(false); }
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("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) => { const handleDeleteMarker = async (id: number) => {
try { try {
await deleteMarker(id); await deleteMarker(id);
setSelected((prev) => prev.filter((current) => current !== id)); if (startMarkerId === String(id)) setStartMarkerId("");
if (endMarkerId === String(id)) setEndMarkerId("");
refreshMarkers(); refreshMarkers();
} catch (err) { } 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 handleSelectMarker = (id: number) => {
const outSeconds = parseTimecode(outValue); const idStr = String(id);
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss). setMarkerSaveError(null);
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds if (startMarkerId === idStr) { setStartMarkerId(""); return; }
? formatTime(outSeconds - inSeconds) if (endMarkerId === idStr) { setEndMarkerId(""); return; }
: null; if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
const handleSaveRange = async () => { setEndMarkerId(idStr);
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 clearSelection = () => { if (!user) {
setSelected([]); return (
setInValue(""); <div className="flex justify-center items-center h-full text-muted text-base">
setOutValue(""); Please log in to control your stream.
setRangeName(""); </div>
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
); );
}, [elapsedSeconds]); }
const runningJobs = jobs.filter((job) => !isSettled(job)).length; const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
if (loading) return null;
if (!user) return <NoAccessPage />;
return ( return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18"> <div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<PageHeader kicker="Ingest" title="Stream panel" /> <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"> {/* Hero status bar */}
{/* ── Main column ───────────────────────────────────────── */} <Box className={clsx(
<div className="flex flex-col gap-[26px]"> "p-6 flex items-center justify-between gap-6 border-l-4",
<Card className="p-7"> isStreaming ? "border-l-live" : "border-l-hairline"
<div className="flex items-baseline justify-between gap-4 pb-5"> )}>
<h2 className="font-heading text-[22px]">Server credentials</h2> <div className="flex items-center gap-3.5">
{isStreaming ? ( <span className="relative flex h-3.5 w-3.5">
<div className="flex items-center gap-2"> {isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
<IsLivePulse /> <span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p> </span>
</div> <div>
) : ( <p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p> {isStreaming && streamDetails?.startDate ? (
)} <p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
</div> ) : !isStreaming ? (
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
<p className={labelClass}>Ingest URL</p> ) : null}
<div className="flex items-center gap-2.5 pt-2"> </div>
<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>
)}
</div> </div>
{/* ── Side panel ────────────────────────────────────────── */} <Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10"> </Box>
{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>
<div className="grid grid-cols-2 gap-3"> {isStreaming && (
<div className="flex flex-col gap-1.5"> <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
<label className={labelClass} htmlFor="range-in">In</label> {/* Marker timeline. First on narrow screens, where marking
<input something as it happens beats the action forms for urgency. */}
id="range-in" <MarkerList
value={inValue} className="lg:order-last lg:sticky lg:top-6"
onChange={(event) => setInValue(event.target.value)} markers={markers}
placeholder="00:00:00" startMarkerId={startMarkerId}
className={clsx(monoInputClass, "bg-card")} 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>
<div className="flex flex-col gap-1.5"> <input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<label className={labelClass} htmlFor="range-out">Out</label> <textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
<input {markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
id="range-out" </>
value={outValue} )}
onChange={(event) => setOutValue(event.target.value)} <PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
placeholder="00:00:00" </Box>
className={clsx(monoInputClass, "bg-card")}
/>
</div>
</div>
<div className="flex items-baseline justify-between gap-3"> <Box className="p-6 flex flex-col gap-3">
<span className="font-body text-[13.5px] text-text-body">Range length</span> <CardHeader icon={Scissors} title="Clip the last few seconds" />
<span className="font-data text-[15px] text-text-primary tabular-nums"> <div className="flex flex-col gap-1.5">
{rangeLength ?? "—"} <label className={labelClass}>Duration (seconds)</label>
</span> <input
</div> type="number"
min={1}
<div className="flex flex-col gap-1.5"> value={clipDuration}
<label className={labelClass} htmlFor="range-name">Name</label> onChange={(e) => setClipDuration(Number(e.target.value))}
<input className={inputClass}
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>
</div> </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"> <Box className="p-5 flex flex-col gap-3">
<div className="flex flex-col gap-2"> <CardHeader icon={Activity} title="Recent actions" />
<h2 className="font-heading text-[22px]">Cut a clip from just now</h2> {jobs.length === 0 ? (
<p className="font-body text-[13.5px] leading-[1.6] text-text-body"> <p className="text-sm text-muted">Clip and save actions will show up here.</p>
Takes the last stretch of stream, counted back from this moment. ) : (
</p> <div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div> </div>
)}
<div className="grid grid-cols-2 gap-3"> </Box>
<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>
</div> </div>
</aside> </div>
</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,48 @@
const API_URL = import.meta.env.VITE_API_URL; import {config} from "../../config.ts";
const API_URL = config.apiUrl;
class AuthError extends Error { class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; } constructor() { super("Not authenticated"); this.name = "AuthError"; }
} }
const isThumbnailAvailable = async (thumbnailUrl: string): Promise<boolean> => { let onUnauthorized: (() => void) | null = null;
const response = await fetch(thumbnailUrl, { credentials: 'include' });
return response.ok;
};
export { API_URL, AuthError, isThumbnailAvailable }; export function registerUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
/**
* Performs a request against the API, sending session cookies and funnelling
* expired/missing sessions through the registered unauthorized handler so the
* UI can drop 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 response = await fetch(url, {
...options,
credentials: 'include'
});
if (response.status == 401 || response.status == 403) {
onUnauthorized?.();
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 };

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,27 @@
import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts"; 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 getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' }); try {
if (!response.ok) return false; const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
const result: APIResponse = await response.json(); return result.status === 'success' && result.data?.isStreaming === true;
return result.status === 'success' && result.data?.isStreaming === true; } catch {
return false;
}
}; };
const getCurrentStream = async (): Promise<StreamStatus> => { const getCurrentStream = async (): Promise<StreamStatus> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' }); const result = await apiFetch<APIResponse>(API_URL + '/api/v1/stream/current');
if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`); if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
return result.data; 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 getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' }); const result = await apiFetch<APIResponse>(API_URL + `/api/v1/stream/history/${userId}`);
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`); if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
return result.data; return result.data;
}; };
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory }; export { getStreamStatus, getCurrentStream, getStreamHistory };

View File

@@ -1,6 +1,9 @@
import type { APIResponse, User } from "../types.ts"; import type { APIResponse, User } from "../types.ts";
import { API_URL } from "./client.ts"; import { API_URL, apiFetch } from "./client.ts";
// login and getUser deliberately use fetch directly: they run when there may be
// no session at all, and getUser is what the unauthorized handler calls to
// refresh — routing them through apiFetch would loop on a 401.
const login = async (googleToken: string): Promise<string> => { const login = async (googleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/users/login', { const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST', method: 'POST',
@@ -18,15 +21,8 @@ const login = async (googleToken: string): Promise<string> => {
}; };
const logout = async (): Promise<void> => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', { const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
method: 'POST', if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
credentials: 'include',
});
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> => { const getUser = async (): Promise<User | null> => {

View File

@@ -1,27 +1,17 @@
import type { APIResponse, Vod } from "../types.ts"; 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 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; return result.data;
}; };
const getVodById = async (id: string): Promise<Vod | null> => { 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; 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 getVodMediaUrl = (id: string): string => API_URL + `/api/v1/vods/${id}/media`;
const patchVod = async (id: number, data: { title?: string; description?: string }): Promise<Vod> => { 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', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data), 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}`); if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
return result.data; return result.data;
}; };
const deleteVod = async (id: number): Promise<void> => { const deleteVod = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/vods/${id}`, { const result = await apiFetch<APIResponse>(API_URL + `/api/v1/vods/${id}`, { method: 'DELETE' });
method: 'DELETE', if (result?.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
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}`);
}; };
export { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod }; 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, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type Vod = { type Vod = {
@@ -46,11 +41,6 @@ type Vod = {
description: string, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type JobResponse = { type JobResponse = {
@@ -65,7 +55,6 @@ type JobResponse = {
type StreamStatus = { type StreamStatus = {
isStreaming: boolean, isStreaming: boolean,
id: number | null, id: number | null,
startTime: string | null
} }
type StreamHistoryItem = { type StreamHistoryItem = {
@@ -83,11 +72,6 @@ type Marker = {
timestamp: string, timestamp: string,
} }
type StreamMetadata = {
url: string,
length: number
}
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
@@ -97,6 +81,5 @@ export type {
JobResponse, JobResponse,
StreamStatus, StreamStatus,
StreamHistoryItem, StreamHistoryItem,
Marker, Marker
StreamMetadata
} }

View File

@@ -7,45 +7,6 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`; 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 // 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. // the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date { 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" }); 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 { function dateToTimeAgo(date: Date): string {
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000); const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [ const units: [number, string][] = [
@@ -79,4 +46,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate }; export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" /> /// <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 { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import flowbiteReact from "flowbite-react/plugin/vite";
export default defineConfig(({ mode }) => { // change to function form export default defineConfig(({ mode }) => { // change to function form
const env = loadEnv(mode, process.cwd(), '') // load all env vars const env = loadEnv(mode, process.cwd(), '') // load all env vars
return { return {
plugins: [react(), tailwindcss(), flowbiteReact()], plugins: [react(), tailwindcss()],
preview: { preview: {
port: 5173, port: 5173,
strictPort: true, 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 install -y --no-install-recommends gettext-base && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View File

@@ -10,6 +10,11 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableAsync @EnableAsync
public class VodSystemApplication { 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) { public static void main(String[] args) {
SpringApplication.run(VodSystemApplication.class, args); SpringApplication.run(VodSystemApplication.class, args);
} }

View File

@@ -14,6 +14,11 @@ import java.util.concurrent.Executor;
public class AsyncConfig implements AsyncConfigurer { public class AsyncConfig implements AsyncConfigurer {
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class); 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") @Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() { public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
@@ -25,6 +30,11 @@ public class AsyncConfig implements AsyncConfigurer {
return executor; return executor;
} }
/**
* Supplies the handler that logs uncaught exceptions from void-returning {@code @Async} methods.
*
* @return the async uncaught-exception handler
*/
@Override @Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> return (ex, method, params) ->

View File

@@ -12,6 +12,11 @@ public class CorsConfig {
@Value("${frontend.url}") @Value("${frontend.url}")
private String frontendUrl; 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 @Bean
public WebMvcConfigurer corsConfigurer() { public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() { return new WebMvcConfigurer() {

View File

@@ -22,6 +22,14 @@ public class SecurityConfig {
this.jwtFilter = jwtFilter; this.jwtFilter = jwtFilter;
} }
/**
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
* installed, 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 @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@@ -30,8 +38,9 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() .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/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll() .requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()

View File

@@ -6,14 +6,12 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders; import org.springframework.http.*;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/api/v1/clips") @RequestMapping("/api/v1/clips")
@@ -26,6 +24,11 @@ public class ClipController {
this.clipService = clipService; 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("") @GetMapping("")
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() { public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser(); 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}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(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}") @PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id, public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) { @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}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(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) @GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id); Resource resource = clipService.downloadClip(id);
@@ -95,8 +124,15 @@ public class ClipController {
.body(resource); .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") @GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadThumbnail(id); Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -104,6 +140,8 @@ public class ClipController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);
@@ -116,11 +154,7 @@ public class ClipController {
clip.getTitle(), clip.getTitle(),
clip.getDescription(), clip.getDescription(),
clip.getDuration(), clip.getDuration(),
clip.getCreatedAt(), clip.getCreatedAt()
clip.getWidth(),
clip.getHeight(),
clip.getFps(),
clip.getFileSize()
); );
} }
} }

View File

@@ -18,6 +18,12 @@ public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private static final String ERROR = "error"; 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 }) @ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) { public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage()); logger.error("MultipartException: {}", ex.getMessage());
@@ -25,6 +31,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); 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 }) @ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) { public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage()); logger.error("MissingServletRequestPartException: {}", ex.getMessage());
@@ -32,6 +44,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); 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 }) @ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage()); logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
@@ -39,6 +57,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response); 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) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) { public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage()); logger.error("IllegalArgumentException: {}", ex.getMessage());
@@ -46,6 +70,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); 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) @ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) { public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage()); logger.error("JobNotFound: {}", ex.getMessage());
@@ -53,6 +83,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); 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) @ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) { public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage()); logger.error("JobNotFinished: {}", ex.getMessage());
@@ -60,6 +96,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); 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) @ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) { public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage()); logger.error("FFMPEGException: {}", ex.getMessage());
@@ -67,6 +109,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); 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) @ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) { public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage()); logger.error("NotAuthenticated: {}", ex.getMessage());
@@ -74,6 +122,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); 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) @ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) { public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage()); logger.error("KeyNotFound: {}", ex.getMessage());
@@ -81,6 +135,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); 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) @ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) { public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage()); logger.error("AlreadyStreaming: {}", ex.getMessage());
@@ -88,6 +148,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); 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) @ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) { public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e); logger.error("Storage operation failed", e);
@@ -95,6 +161,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); 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) @ExceptionHandler(ClipNotFound.class)
public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) { public ResponseEntity<APIResponse<Void>> handleClipNotFound(ClipNotFound ex) {
logger.error("ClipNotFound: {}", ex.getMessage()); logger.error("ClipNotFound: {}", ex.getMessage());
@@ -102,6 +174,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); 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) @ExceptionHandler(ClipMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) { public ResponseEntity<APIResponse<Void>> handleClipMediaUnavailable(ClipMediaUnavailable ex) {
logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage()); logger.error("ClipMediaUnavailable (clip {}): {}", ex.getClipId(), ex.getMessage());
@@ -109,6 +187,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); 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) @ExceptionHandler(VodNotFound.class)
public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) { public ResponseEntity<APIResponse<Void>> handleVodNotFound(VodNotFound ex) {
logger.error("VodNotFound: {}", ex.getMessage()); logger.error("VodNotFound: {}", ex.getMessage());
@@ -116,6 +200,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); 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) @ExceptionHandler(VodMediaUnavailable.class)
public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) { public ResponseEntity<APIResponse<Void>> handleVodMediaUnavailable(VodMediaUnavailable ex) {
logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage()); logger.error("VodMediaUnavailable (vod {}): {}", ex.getVodId(), ex.getMessage());
@@ -123,6 +213,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response); 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) @ExceptionHandler(NotStreaming.class)
public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) { public ResponseEntity<APIResponse<Void>> handleNotStreaming(NotStreaming ex) {
logger.error("NotStreaming: {}", ex.getMessage()); logger.error("NotStreaming: {}", ex.getMessage());
@@ -130,6 +226,12 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); 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) @ExceptionHandler(MarkerNotFound.class)
public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) { public ResponseEntity<APIResponse<Void>> handleMarkerNotFound(MarkerNotFound ex) {
logger.error("MarkerNotFound: {}", ex.getMessage()); logger.error("MarkerNotFound: {}", ex.getMessage());

View File

@@ -28,6 +28,13 @@ public class JobController {
this.jobRegistryService = jobRegistryService; 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}") @GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) { public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(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") @GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) { public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid); Job job = jobRegistryService.getJob(uuid);

View File

@@ -22,6 +22,11 @@ public class MarkerController {
this.markerService = markerService; 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("") @GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() { public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream() 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}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) { public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(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("") @PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) { public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message()); 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}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id); markerService.deleteMarker(id);

View File

@@ -26,6 +26,14 @@ public class MediaController {
this.mediaService = mediaService; 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) @PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress( public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file, @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") @PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save( public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException { @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") @PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers( public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException { @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") @PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip( public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException { @RequestBody ClipSectionRequest clipSectionRequest) throws IOException {

View File

@@ -1,17 +1,14 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse; import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService; import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -21,25 +18,20 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class); private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success"; 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; 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) { public StreamController(StreamService streamService) {
this.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 // Called by nginx on_publish
@PostMapping("/start") @PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) { 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)); 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 // Called by nginx on_publish_done
@PostMapping("/stop") @PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) { 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)); 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 // Called by nginx on_update
@PostMapping("/heartbeat") @PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) { 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)); 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}") @GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) { public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId); List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history)); 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 — * Reports whether the authenticated user is currently streaming.
* 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 * @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
* publishes the nginx-rtmp container. * 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") @GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() { public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS, return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved", "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", "Streaming status retrieved",
new StreamResponse(false, null, null)))); new StreamResponse(false, null))));
} }
} }

View File

@@ -1,7 +1,9 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamKey;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.Token; import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService; import com.ddf.vodsystem.services.UserService;
@@ -27,6 +29,12 @@ public class UserController {
this.userService = userService; this.userService = userService;
} }
/**
* 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") @GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() { public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -40,6 +48,17 @@ public class UserController {
); );
} }
/**
* Authenticates a user from a Google ID token and issues a session JWT.
* <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
@@ -61,6 +80,13 @@ public class UserController {
); );
} }
/**
* Logs the user out by clearing the session cookie.
*
* @param response the response the expired cookie is written to
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) { HttpServletRequest request) {
@@ -78,4 +104,12 @@ public class UserController {
new APIResponse<>(SUCCESS, "Logged out successfully", null) new APIResponse<>(SUCCESS, "Logged out successfully", null)
); );
} }
@PostMapping("/me/stream-key")
public ResponseEntity<APIResponse<StreamKey>> regenerateStreamKey() {
String streamKey = userService.regenerateStreamKey();
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Stream key successfully", new StreamKey(streamKey))
);
}
} }

View File

@@ -6,14 +6,12 @@ import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Vod; import com.ddf.vodsystem.entities.Vod;
import com.ddf.vodsystem.services.VodService; import com.ddf.vodsystem.services.VodService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders; import org.springframework.http.*;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/api/v1/vods") @RequestMapping("/api/v1/vods")
@@ -26,6 +24,11 @@ public class VodController {
this.vodService = vodService; 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("") @GetMapping("")
public ResponseEntity<APIResponse<List<VodResponse>>> getVods() { public ResponseEntity<APIResponse<List<VodResponse>>> getVods() {
List<VodResponse> vods = vodService.getUserVods().stream() List<VodResponse> vods = vodService.getUserVods().stream()
@@ -35,12 +38,25 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vods retrieved successfully", vods)); 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}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) { public ResponseEntity<APIResponse<VodResponse>> getVodById(@PathVariable Long id) {
VodResponse vod = convertToDTO(vodService.getVodById(id)); VodResponse vod = convertToDTO(vodService.getVodById(id));
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod retrieved successfully", vod)); 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}") @PatchMapping("/{id}")
public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id, public ResponseEntity<APIResponse<VodResponse>> updateVod(@PathVariable Long id,
@RequestBody VodUpdateRequest updateFields) { @RequestBody VodUpdateRequest updateFields) {
@@ -48,6 +64,12 @@ public class VodController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Vod updated successfully", vod)); 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}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteVod(@PathVariable Long id) {
vodService.deleteVod(id); vodService.deleteVod(id);
@@ -55,6 +77,13 @@ public class VodController {
"Vod with ID " + id + " has been deleted")); "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) @GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadVod(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadVod(id); Resource resource = vodService.downloadVod(id);
@@ -72,8 +101,15 @@ public class VodController {
.body(resource); .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") @GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException {
Resource resource = vodService.downloadThumbnail(id); Resource resource = vodService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -81,6 +117,8 @@ public class VodController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);
@@ -93,11 +131,7 @@ public class VodController {
vod.getTitle(), vod.getTitle(),
vod.getDescription(), vod.getDescription(),
vod.getDuration(), vod.getDuration(),
vod.getCreatedAt(), vod.getCreatedAt()
vod.getWidth(),
vod.getHeight(),
vod.getFps(),
vod.getFileSize()
); );
} }
} }

View File

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

View File

@@ -0,0 +1,4 @@
package com.ddf.vodsystem.controllers.dto;
public record StreamKey (String streamKey) {
}

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; package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record StreamResponse( public record StreamResponse(
boolean isStreaming, boolean isStreaming,
Long id, Long id
Instant startTime
) { ) {
} }

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,11 @@ public class ClipMediaUnavailable extends RuntimeException {
this.clipId = clipId; this.clipId = clipId;
} }
/**
* Returns the ID of the clip whose media was unavailable.
*
* @return the affected clip's ID
*/
public Long getClipId() { public Long getClipId() {
return clipId; return clipId;
} }

View File

@@ -12,6 +12,11 @@ public class VodMediaUnavailable extends RuntimeException {
this.vodId = vodId; this.vodId = vodId;
} }
/**
* Returns the ID of the VoD whose media was unavailable.
*
* @return the affected VoD's ID
*/
public Long getVodId() { public Long getVodId() {
return vodId; return vodId;
} }

View File

@@ -10,6 +10,12 @@ import java.util.List;
@Repository @Repository
public interface ClipRepository extends JpaRepository<Clip, Long> { public interface ClipRepository extends JpaRepository<Clip, Long> {
/**
* Finds all clips owned by the given user.
*
* @param user the owning user
* @return the user's clips
*/
@Query("SELECT c FROM Clip c WHERE c.user = ?1") @Query("SELECT c FROM Clip c WHERE c.user = ?1")
List<Clip> findByUser(User user); List<Clip> findByUser(User user);
} }

View File

@@ -11,9 +11,21 @@ import java.util.List;
@Repository @Repository
public interface MarkerRepository extends JpaRepository<Marker, Long> { public interface MarkerRepository extends JpaRepository<Marker, Long> {
/**
* Finds all markers owned by the given user.
*
* @param user the owning user
* @return the user's markers
*/
@Query("SELECT m FROM Marker m WHERE m.user = ?1") @Query("SELECT m FROM Marker m WHERE m.user = ?1")
List<Marker> findByUser(User user); List<Marker> findByUser(User user);
/**
* Finds all markers timestamped at or before the given instant.
*
* @param time the cutoff instant (inclusive)
* @return markers at or before {@code time}
*/
@Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1") @Query("SELECT m FROM Marker m WHERE m.timestamp <= ?1")
List<Marker> findAllBefore(Instant time); List<Marker> findAllBefore(Instant time);
} }

View File

@@ -11,7 +11,27 @@ import java.util.Optional;
@Repository @Repository
public interface StreamRepository extends JpaRepository<Stream, Long> { public interface StreamRepository extends JpaRepository<Stream, Long> {
/**
* Finds the user's active (not-yet-ended) stream, if any.
*
* @param user the owning user
* @return the active stream, or empty if none
*/
Optional<Stream> findByUserAndEndDateIsNull(User user); Optional<Stream> findByUserAndEndDateIsNull(User user);
/**
* Finds all streams belonging to the given user.
*
* @param user the owning user
* @return the user's streams
*/
List<Stream> findByUser(User user); List<Stream> findByUser(User user);
/**
* Finds active streams whose last heartbeat predates the given cutoff, i.e. stale streams.
*
* @param cutoff the last-seen cutoff; streams last seen before this are returned
* @return the stale active streams
*/
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff); List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
} }

View File

@@ -9,9 +9,21 @@ import java.util.Optional;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
/**
* Finds a user by their Google account ID.
*
* @param googleId the Google subject identifier
* @return the matching user, or empty if none
*/
@Query("SELECT u FROM User u WHERE u.googleId = ?1") @Query("SELECT u FROM User u WHERE u.googleId = ?1")
Optional<User> findByGoogleId(String googleId); Optional<User> findByGoogleId(String googleId);
/**
* Finds a user by their stream key.
*
* @param streamKey the stream key to match
* @return the matching user, or empty if none
*/
@Query("SELECT u FROM User u WHERE u.streamKey = ?1") @Query("SELECT u FROM User u WHERE u.streamKey = ?1")
Optional<User> findByStreamKey(String streamKey); Optional<User> findByStreamKey(String streamKey);
} }

View File

@@ -11,6 +11,12 @@ import java.util.List;
@Repository @Repository
public interface VodRepository extends JpaRepository<Vod, Long> public interface VodRepository extends JpaRepository<Vod, Long>
{ {
/**
* Finds all VoDs owned by the given user.
*
* @param user the owning user
* @return the user's VoDs
*/
@Query("SELECT v FROM Vod v WHERE v.user = ?1") @Query("SELECT v FROM Vod v WHERE v.user = ?1")
List<Vod> findByUser(User user); List<Vod> findByUser(User user);
} }

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