1 Commits

Author SHA1 Message Date
c388271ffd remove unncessary packages & files
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-15 17:40:19 +01:00
118 changed files with 1586 additions and 3822 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# 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,
},
})
```

View File

@@ -1,10 +0,0 @@
#!/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

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

@@ -3,28 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/small_icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>

View File

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

View File

@@ -8,11 +8,13 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"postinstall": "flowbite-react patch"
},
"dependencies": {
"@react-oauth/google": "^0.12.2",
"clsx": "^2.1.1",
"flowbite-react": "^0.11.8",
"lucide-react": "^0.511.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",

42
frontend/src/App.css Normal file
View File

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

View File

@@ -15,11 +15,6 @@ import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
import {config} from "./config.ts";
import NotFound from "./pages/NotFound.tsx";
import AuthProvider from "./auth/AuthProvider.tsx";
import ThemeProvider from "./theme/ThemeProvider.tsx";
import RequireAuth from "./auth/RequireAuth.tsx";
function App() {
@@ -28,33 +23,24 @@ function App() {
}, []);
return (
<GoogleOAuthProvider clientId={config.googleClientId}>
<ThemeProvider>
<AuthProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route element={<RequireAuth />}>
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</AuthProvider>
</ThemeProvider>
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route>
</Routes>
</Router>
</ GoogleOAuthProvider>
);
}

View File

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

View File

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

View File

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

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

View File

@@ -1,5 +1,4 @@
import clsx from "clsx";
import React from "react";
type Props = {
children: React.ReactNode;
@@ -7,7 +6,7 @@ type Props = {
};
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-card border-hairline shadow-sm rounded-lg", className)}>
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{children}
</div>
);

View File

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

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-hover px-3 py-2 text-sm rounded-md", className)}
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)}
>
{item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 px-3 py-2 text-sm transition-colors duration-150"
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<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">
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{children}
</ul>
)}

View File

@@ -7,8 +7,8 @@ type Props = {
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-text-secondary">{label}</label>
<div className="w-px h-5 text-text-secondary mx-2" />
<label className="w-full text-sm text-gray-600">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" />
{children}
</div>
);

View File

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

View File

@@ -1,19 +1,21 @@
import { Menu, X, Sun, Moon } from 'lucide-react';
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
import { useTheme } from "../theme/useTheme.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
@@ -21,12 +23,12 @@ type props = {
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
@@ -35,38 +37,34 @@ const Topbar = ({
}
login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const themeLabel = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
const handleLogout = () => {
logout()
.then(() => navigate("/"))
.then(() => {
fetchUser();
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")}>
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<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"}>
{ user ? (
<div>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
@@ -78,8 +76,8 @@ const Topbar = ({
{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 className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
@@ -88,18 +86,16 @@ const Topbar = ({
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-error font-medium"} />
className={"text-red-500 font-medium"} />
</Dropdown>
</div>
) :
(
) :
(
<GoogleLogin
shape={"pill"}
theme={theme === "dark" ? "filled_black" : "outline"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
)}
</div>
)
}

View File

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

View File

@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props}
/>
);

View File

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

View File

@@ -1,5 +1,5 @@
import MenuButton from "./MenuButton.tsx";
import { Link, useLocation } from "react-router-dom";
import React from "react";
type Props = {
url: string;
@@ -13,15 +13,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
return (
<Link className="w-full" to={url}>
<button className={`flex items-center gap-2.5 w-full text-sm
font-body p-2 hover:bg-hover hover:text-text-strong
rounded-md
${isActive ?
"bg-card text-text-strong font-medium shadow-sm"
:
"text-text-primary"}`}>
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
{logo}{label}
</button>
</MenuButton>
</Link>
)
}

View File

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

@@ -8,7 +8,7 @@ type Props = {
className?: string;
};
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";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -26,7 +26,7 @@ export default function ConfigBox({ setMetadata, className }: Props) {
return (
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"

View File

@@ -1,4 +1,4 @@
import PrimaryButton from "../buttons/PrimaryButton.tsx";
import BlueButton from "../buttons/BlueButton.tsx";
import React from "react";
type props = {
@@ -24,24 +24,24 @@ const Spinner = () => (
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return (
<div className={"flex flex-col gap-3"}>
<PrimaryButton
<BlueButton
onClick={dataSend}
disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</PrimaryButton>
</BlueButton>
<div className="h-10 flex items-center">
{ downloadable ?
(<PrimaryButton className="w-full" onClick={handleDownload}>
(<BlueButton className="w-full" onClick={handleDownload}>
Download
</PrimaryButton>)
</BlueButton>)
:(
<progress
value={progress}
className="w-full h-1.5 rounded bg-fields">
className="w-full h-1.5 rounded bg-gray-200">
</progress>
)
}

View File

@@ -9,15 +9,12 @@ type Props = {
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-body text-text-strong font-bold text-sm">
Title <span className="text-error">*</span>
</p>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<input
type="text"
required
placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
);

View File

@@ -62,11 +62,11 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
</div>
<button
onClick={togglePlay}
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<span className="font-data text-sm text-text-secondary tabular-nums">
<span>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span>
</div>

View File

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

View File

@@ -1,9 +1,9 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = {
id: number,
@@ -18,12 +18,12 @@ type VideoCardProps = {
itemLabel?: string,
}
const fallbackThumbnail = "public/default_thumbnail.png";
const API_URL = config.apiUrl;
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [imgFailed, setImgFailed] = useState(false);
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -36,6 +36,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => {
isThumbnailAvailable(thumbnailUrl)
.then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false));
}, [thumbnailUrl]);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
@@ -59,13 +65,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={imgFailed ? fallbackThumbnail : thumbnailUrl}
onError={() => setImgFailed(true)}
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<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">
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
{formatTime(duration)}
</span>
@@ -77,18 +82,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-media-backdrop/75 hover:bg-media-backdrop/90 text-on-dark rounded p-0.5 transition-colors"
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil size={13} /> Edit
</button>
@@ -96,7 +101,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 size={13} /> Delete
</button>
@@ -104,17 +109,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
>
Cancel
</button>
@@ -128,10 +133,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</div>
<div className="flex flex-col p-2">
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title}
</p>
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div>
</div>
</Link>

View File

@@ -1,5 +0,0 @@
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,170 +1,45 @@
@import "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"] *));
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
/* Breakpoints */
--breakpoint-3xl: 1920px;
/* Letterbox behind video. Dark in both themes — it is a cinema surface,
not an ink colour — so `text-on-dark` always reads on top of it. */
--color-media-backdrop: #2B2620;
--color-primary: oklch(0.55 0.21 254);
--color-primary-pressed: oklch(0.45 0.21 254);
--color-secondary: oklch(0.94 0.01 250);
--color-accent: oklch(0.68 0.20 288);
--color-text: oklch(0.17 0.01 270);
--color-background: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #2B2620;
--color-text-strong: #35302A;
--color-text-body: #554D44;
--color-text-secondary: #6E6559;
--color-muted: #9A9084;
--color-faint-mono: #A79C8E;
/* ── Accents ─────────────────────────────────────────────────────────── */
--color-terracotta: #A85A38;
--color-terracotta-hover: #94492A;
--color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #B5432E;
--color-error: #B5432E;
/* ── Ink on dark / accent surfaces ───────────────────────────────────── */
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2;
--color-hover: #E7E0D2;
--color-selected: #F6EFE6;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
/* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}
/* Dark mode. Only token values change here: every component already styles
itself with the semantic utilities above, so the whole UI follows from this
one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes.
Soft charcoal rather than pure black, with only a trace of warmth so it
still sits with the paper tones of the light theme. Ink is a warm off-white
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
light theme: sidebar recessed, cards raised, fields inset into the card. */
:root[data-theme="dark"] {
color-scheme: dark;
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #161615;
--color-sidebar: #111111;
--color-card: #1F1F1E;
--color-fields: #1A1A19;
--color-dropzone: #1C1C1B;
--color-media-backdrop: #0B0B0B;
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F5F4EF;
--color-text-strong: #E8E6E0;
--color-text-body: #C9C7C0;
--color-text-secondary: #A2A099;
--color-muted: #87857F;
--color-faint-mono: #6C6B66;
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */
--color-terracotta: #D97757;
--color-terracotta-hover: #E28B6E;
--color-terracotta-light: #BC6A4E;
--color-terracotta-disabled: #46372F;
--color-olive: #A7AF84;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0644E;
--color-error: #E0644E;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #1A1410;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #282827;
--color-hover: #2A2A29;
--color-selected: #2A2522;
--color-ring: #3B3B3A;
--color-inactive: #333332;
--color-inactive-strong: #5A5955;
#range-slider .range-slider__range {
background: var(--color-primary);
}
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
--tw-shadow-color: rgb(0 0 0 / 0.45);
#range-slider .range-slider__thumb {
background: var(--color-primary);
}
:root {
font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: light;
}
body {
background-color: var(--color-background);
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--color-text-body);
}
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
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

@@ -3,13 +3,27 @@ import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import { useAuth } from "../auth/useAuth";
import type {User} from "../utils/types";
import { getUser } from "../utils/api/users";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (!user) return;
@@ -20,13 +34,16 @@ const MainLayout = () => {
}, [user]);
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-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />

View File

@@ -6,11 +6,13 @@ import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media"
import { getJob, downloadJob } from "../utils/api/jobs"
import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => {
const location = useLocation();
const localFile: File = location.state?.file;
@@ -61,18 +63,12 @@ const ClipEdit = () => {
const sendData = async () => {
if (!localFile || uploading) return;
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
setUploading(true);
setError(null);
let jobId: string;
try {
jobId = await compress(localFile, { ...outputMetadata, title });
jobId = await compress(localFile, outputMetadata);
setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -83,7 +79,7 @@ const ClipEdit = () => {
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
};
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
const pollProgress = async (jobId: string, intervalId: number) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
@@ -108,14 +104,14 @@ const ClipEdit = () => {
const handleDownload = async () => {
if (!uploadedId) return;
let blob: Blob;
try {
blob = await downloadJob(uploadedId);
} catch (err) {
console.error('Download failed', err);
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) {
console.error('Download failed');
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -132,7 +128,7 @@ const ClipEdit = () => {
<video
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-media-backdrop"}
className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
@@ -146,7 +142,7 @@ const ClipEdit = () => {
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className={"w-full accent-terracotta"}
className={"w-full accent-primary"}
/>
<PlaybackSlider
@@ -154,7 +150,7 @@ const ClipEdit = () => {
videoMetadata={metadata}
sliderValue={playbackValue}
setSliderValue={setPlaybackValue}
className={"w-full accent-terracotta"}
className={"w-full accent-primary"}
/>
<ClipRangeSlider
@@ -162,11 +158,11 @@ const ClipEdit = () => {
videoMetadata={metadata}
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2"}
className={"w-full mt-2 bg-primary"}
/>
{error && (
<div className={"text-error text-center mt-2"}>
<div className={"text-red-600 text-center mt-2"}>
{error}
</div>
)}

View File

@@ -1,11 +1,12 @@
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx";
const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const press = () => {
if (!file) {
@@ -16,40 +17,23 @@ const ClipUpload = () => {
};
return (
<div className="flex justify-center p-8">
<div className="flex items-start justify-center p-8 min-h-full">
<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>
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<button
onClick={press}
className={"hover:bg-hover p-1 rounded-md"}
>
Upload
</button>
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/>
{error && <p className="text-sm font-body text-center">{error}</p>}
<BlueButton onClick={press}>Upload</BlueButton>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Box>
</div>
)

View File

@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const EditClip = () => {
const { id } = useParams();
@@ -50,20 +50,20 @@ const EditClip = () => {
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
{loading ? (
<div className="text-muted text-sm">Loading...</div>
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Title</label>
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
@@ -74,7 +74,7 @@ const EditClip = () => {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Description</label>
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -84,17 +84,17 @@ const EditClip = () => {
/>
</div>
{error && <p className="text-sm text-error">{error}</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</PrimaryButton>
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>

View File

@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getVodById, patchVod } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const EditVod = () => {
const { id } = useParams();
@@ -50,20 +50,20 @@ const EditVod = () => {
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
{loading ? (
<div className="text-muted text-sm">Loading...</div>
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Title</label>
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
@@ -74,7 +74,7 @@ const EditVod = () => {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-text-secondary">Description</label>
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -84,17 +84,17 @@ const EditVod = () => {
/>
</div>
{error && <p className="text-sm text-error">{error}</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</PrimaryButton>
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>

View File

@@ -43,10 +43,10 @@ const Home = () => {
alt="VoD System Logo"
className="h-24 mb-6 object-contain"
/>
<h1 className="text-4xl font-heading text-text-primary">
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
A home for your clips.
</h1>
<p className="text-text-secondary">
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
@@ -56,21 +56,21 @@ const Home = () => {
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
<Icon size={18} className="text-terracotta" />
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div>
<div>
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-muted">
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div>

View File

@@ -202,16 +202,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (notAuthenticated) {
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
<Lock size={24} className="text-muted" />
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
<Lock size={24} className="text-gray-400" />
</div>
<div>
<p className="text-2xl font-heading text-text-primary">Not authenticated</p>
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
<ArrowLeft size={16} />
Go back
@@ -224,7 +224,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
<div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */}
<div
ref={containerRef}
className="relative bg-media-backdrop rounded-xl overflow-hidden flex-1 min-h-0 select-none"
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
@@ -265,7 +265,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Buffering spinner */}
{isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-on-dark/20 border-t-on-dark animate-spin" />
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
@@ -282,20 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-on-dark/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-on-dark/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-terracotta-light rounded-full" style={{ width: `${progressPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-on-dark rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
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"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-on-dark">
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors">
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
@@ -303,7 +303,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
@@ -312,18 +312,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-on-dark cursor-pointer"
className="w-16 accent-white cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-data tabular-nums text-on-dark/80">
<span className="text-xs font-mono tabular-nums text-white/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-on-dark/70 transition-colors">
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
@@ -331,17 +331,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div>
</div>
{error && <div className="text-error text-sm mt-3">{error}</div>}
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!mediaLoaded && !error && (
<div className="text-muted text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-inactive border-t-muted animate-spin" />
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box>
</div>
);

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips";
import { stringToDate } from "../utils/utils";
import type { Clip } from "../utils/types";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
getClips()
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err))
.finally(() => setLoading(false));
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err));
}, []);
const handleDelete = async (id: number) => {
@@ -30,12 +26,10 @@ const MyClips = () => {
return (
<div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
<TimeGroupedGrid
items={clips}
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
@@ -45,17 +39,17 @@ const MyClips = () => {
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
)}
/>
))}
</div>
{clips.length === 0 && !error && !loading && (
<div className="flex flex-col items-center justify-center py-16 text-muted">
{clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
</div>
);
}

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods";
import { stringToDate } from "../utils/utils";
import type { Vod } from "../utils/types";
const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
getVods()
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
.finally(() => setLoading(false));
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []);
const handleDelete = async (id: number) => {
@@ -30,12 +26,10 @@ const MyVods = () => {
return (
<div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
<TimeGroupedGrid
items={vods}
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
@@ -48,17 +42,17 @@ const MyVods = () => {
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)}
/>
)}
/>
))}
</div>
{vods.length === 0 && !error && !loading && (
<div className="flex flex-col items-center justify-center py-16 text-muted">
{vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div>
)}
{error && <p className="text-sm text-error mt-4">{error}</p>}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
</div>
);
};

View File

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

View File

@@ -1,14 +1,19 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
import { useAuth } from "../auth/useAuth.ts";
import { getUser } from "../utils/api/users.ts";
import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => {
const { user } = useAuth();
const [user, setUser] = useState<User | null>(null);
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
getUser().then(setUser).catch(console.error);
}, []);
const handleCopy = () => {
if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey);
@@ -18,7 +23,7 @@ const Profile = () => {
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to view your profile.
</div>
);
@@ -26,7 +31,7 @@ const Profile = () => {
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
<Box className="p-6 mb-4 flex items-center gap-5">
<img
@@ -36,31 +41,31 @@ const Profile = () => {
className="w-20 h-20 rounded-full shadow-sm"
/>
<div>
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
<p className="text-sm text-text-secondary">@{user.username}</p>
<p className="text-sm text-text-secondary">{user.email}</p>
<p className="text-xs text-muted mt-1">
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400 mt-1">
Member since {formatLocalDate(user.createdAt)}
</p>
</div>
</Box>
<Box className="p-6">
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
<p className="text-sm text-text-secondary mb-4">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming.
Keep it private — anyone with this key can stream to your account.
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong overflow-hidden">
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
</div>
<button
onClick={() => setKeyVisible(v => !v)}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
@@ -68,9 +73,9 @@ const Profile = () => {
<button
onClick={handleCopy}
title="Copy key"
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{copied ? <Check size={16} className="text-olive" /> : <Copy size={16} />}
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button>
</div>
</Box>

View File

@@ -1,20 +1,18 @@
import { useEffect, useState } from "react";
import { Radio, Check, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
import clsx from "clsx";
import Box from "../components/Box.tsx";
import CardHeader from "../components/CardHeader.tsx";
import MarkerList from "../components/stream/MarkerList.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
import { useAuth } from "../auth/useAuth.ts";
import BlueButton from "../components/buttons/BlueButton.tsx";
import { getUser } from "../utils/api/users.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatLocalDate, stringToDate } from "../utils/utils.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
const inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
type TrackedJob = {
uuid: string;
@@ -24,40 +22,52 @@ type TrackedJob = {
errorOutput: string | null;
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
<div className="flex items-center gap-2.5">
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
</div>
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-error mt-0.5 flex-shrink-0" />
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-olive flex-shrink-0" />
<p className="text-text-body">{job.label} saved</p>
<Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-gray-700">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-terracotta animate-spin flex-shrink-0" />
<Loader2 size={15} className="text-primary 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" />
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
</div>
</div>
);
};
const StreamControl = () => {
const { user } = useAuth();
const [user, setUser] = useState<User | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null);
const [clipDuration, setClipDuration] = useState(30);
@@ -75,6 +85,10 @@ const StreamControl = () => {
const isStreaming = streamStatus?.isStreaming ?? false;
useEffect(() => {
getUser().then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -93,10 +107,16 @@ const StreamControl = () => {
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [streamDetails?.startDate]);
const refreshMarkers = () => {
getMarkers()
.then((data) => setMarkers(data
.sort((a, b) => stringToDate(b.timestamp).getTime() - stringToDate(a.timestamp).getTime())))
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {});
};
@@ -133,15 +153,18 @@ const StreamControl = () => {
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
};
const handleAddMarker = async (message: string) => {
const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return;
setMarkerBusy(true);
setMarkerError(null);
try {
await createMarker(message);
await createMarker(markerMessage.trim());
setMarkerMessage("");
refreshMarkers();
return true;
} catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
return false;
} finally {
setMarkerBusy(false);
}
};
@@ -160,7 +183,7 @@ const StreamControl = () => {
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Pick a start and an end marker from the timeline");
setMarkerSaveError("Tap two markers above to pick a start and end");
return;
}
try {
@@ -191,7 +214,7 @@ const StreamControl = () => {
}
};
const handleSelectMarker = (id: number) => {
const handleChipClick = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
@@ -203,88 +226,112 @@ const StreamControl = () => {
if (!user) {
return (
<div className="flex justify-center items-center h-full text-muted text-base">
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to control your stream.
</div>
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
{/* Hero status bar */}
<Box className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4",
isStreaming ? "border-l-live" : "border-l-hairline"
isStreaming ? "border-l-red-500" : "border-l-gray-200"
)}>
<div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-3.5">
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-live" : "bg-inactive-strong")} />
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
</span>
<div>
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
{isStreaming && streamDetails?.startDate ? (
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
) : !isStreaming ? (
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
<Radio size={28} className={isStreaming ? "text-live" : "text-inactive"} />
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
)}
</Box>
{isStreaming && (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_20rem] gap-5 items-start">
{/* Marker timeline. First on narrow screens, where marking
something as it happens beats the action forms for urgency. */}
<MarkerList
className="lg:order-last lg:sticky lg:top-6"
markers={markers}
startMarkerId={startMarkerId}
endMarkerId={endMarkerId}
error={markerError}
onAdd={handleAddMarker}
onSelect={handleSelectMarker}
onDelete={handleDeleteMarker}
/>
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-accent flex-shrink-0" />
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
/>
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</BlueButton>
</div>
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
return (
<div key={m.id} className="group relative flex-shrink-0">
<button
onClick={() => handleChipClick(m.id)}
className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
)}
>
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
<span className="text-[11px] text-gray-400">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-primary font-medium"> · start</span>}
{isEnd && <span className="text-accent font-medium"> · end</span>}
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
title="Delete marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
>
<X size={10} />
</button>
</div>
);
})}
</div>
)}
</Box>
{/* 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>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-error">{markerSaveError}</p>}
</>
)}
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
</Box>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Box className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5">
@@ -299,22 +346,51 @@ const StreamControl = () => {
</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>
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Box>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
<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-gray-400">Add at least two markers above to save a section between them.</p>
) : (
<div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div>
<>
<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-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-gray-300">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
</div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
</>
)}
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
</Box>
</div>
</div>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
) : (
<div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div>
)}
</Box>
</>
)}
</div>
);

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,12 +21,6 @@ function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOption
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
// Clock time only, no date — markers within a stream are all from the same day,
// so the date would just be noise beside them.
function formatLocalTime(dateString: string): string {
return stringToDate(dateString).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function dateToTimeAgo(date: Date): string {
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [
@@ -46,52 +40,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// Weeks start on Monday.
function startOfWeek(date: Date): Date {
const day = startOfDay(date);
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
return day;
}
// Buckets a date relative to now. Anything before last month falls back to its
// month and year, so older items still group sensibly.
function timeRangeLabel(date: Date, now: Date = new Date()): string {
const today = startOfDay(now);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const thisWeek = startOfWeek(now);
const lastWeek = new Date(thisWeek);
lastWeek.setDate(thisWeek.getDate() - 7);
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= thisWeek) return "This Week";
if (date >= lastWeek) return "Last Week";
if (date >= thisMonth) return "This Month";
if (date >= lastMonth) return "Last Month";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
type TimeGroup<T> = { label: string, items: T[] };
// Expects items already sorted newest first; groups keep that order.
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
const now = new Date();
const groups: TimeGroup<T>[] = [];
for (const item of items) {
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
const last = groups[groups.length - 1];
if (last?.label === label) last.items.push(item);
else groups.push({ label, items: [item] });
}
return groups;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
export type { TimeGroup };
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };

View File

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

View File

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

View File

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

10
pom.xml
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,14 +24,6 @@ public class StreamController {
this.streamService = streamService;
}
/**
* Starts a new stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish} RTMP callback when a broadcast begins.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} wrapping the newly started {@link Stream}
*/
// Called by nginx on_publish
@PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
@@ -40,14 +32,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
}
/**
* Ends the active stream for the user owning the given stream key.
* <p>
* Called by nginx via its {@code on_publish_done} RTMP callback when a broadcast stops.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the stream was ended
*/
// Called by nginx on_publish_done
@PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
@@ -56,14 +40,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
}
/**
* Records a liveness heartbeat for the active stream, keeping it from being reaped as stale.
* <p>
* Called periodically by nginx via its {@code on_update} RTMP callback.
*
* @param streamKey the RTMP stream key identifying the broadcasting user
* @return {@code 200 OK} confirming the heartbeat was recorded
*/
// Called by nginx on_update
@PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
@@ -71,24 +47,12 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
}
/**
* Retrieves the stream history for the given user.
*
* @param userId the ID of the user whose stream history to retrieve; must be the authenticated user
* @return {@code 200 OK} wrapping the user's past and current {@link Stream}s
*/
@GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
/**
* Reports whether the authenticated user is currently streaming.
*
* @return {@code 200 OK} wrapping a {@link StreamResponse} indicating the live status and, if live,
* the active stream's ID
*/
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
package com.ddf.vodsystem.dto.properties;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
@Validated
@ConfigurationProperties(prefix = "app.security")
public record AuthProperties (
@NotBlank @Size(min = 32) String jwtSecret,
@Valid @NotNull Expiration expiration
) {
/**
*
* @param jwt
* @param refreshToken
* @param tokenFamily
* @param gracePeriod
*/
public record Expiration (
@NotNull Duration jwt,
@NotNull Duration refreshToken,
@NotNull Duration tokenFamily,
@NotNull Duration gracePeriod
) {}
}

View File

@@ -1,37 +0,0 @@
package com.ddf.vodsystem.entities;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "refresh_tokens")
@Data
public class RefreshToken {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "family_id", nullable = false)
private TokenFamily tokenFamily;
@Column(name = "token_hash", unique = true, nullable = false, length = 32)
private byte[] tokenHash;
@Column(name = "created_at", nullable = false)
@CreationTimestamp
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "revoked_at")
private Instant revokedAt;
}

View File

@@ -1,31 +0,0 @@
package com.ddf.vodsystem.entities;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "token_families")
@Data
public class TokenFamily {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "created_at", nullable = false)
@CreationTimestamp
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "revoked_at")
private Instant revokedAt;
}

View File

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

View File

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

View File

@@ -10,12 +10,6 @@ import java.util.List;
@Repository
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")
List<Clip> findByUser(User user);
}

View File

@@ -11,21 +11,9 @@ import java.util.List;
@Repository
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")
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")
List<Marker> findAllBefore(Instant time);
}

View File

@@ -1,17 +0,0 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.RefreshToken;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
Optional<RefreshToken> findByHash(byte[] tokenHash);
}

View File

@@ -11,27 +11,7 @@ import java.util.Optional;
@Repository
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);
/**
* Finds all streams belonging to the given user.
*
* @param user the owning user
* @return the user's streams
*/
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);
}

View File

@@ -1,30 +0,0 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.TokenFamily;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@Repository
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
/**
* Deletes families that can no longer be refreshed: revoked, expired, or with every refresh
* token expired. Their refresh tokens are removed by the {@code ON DELETE CASCADE} foreign key.
*
* @return the number of families deleted
*/
@Modifying
@Query("""
DELETE FROM TokenFamily f
WHERE f.revokedAt IS NOT NULL
OR f.expiresAt <= ?1
OR NOT EXISTS (
SELECT 1 FROM RefreshToken r
WHERE r.tokenFamily = f AND r.expiresAt > ?1
)
""")
int deleteDead(Instant now);
}

View File

@@ -9,21 +9,9 @@ import java.util.Optional;
@Repository
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")
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")
Optional<User> findByStreamKey(String streamKey);
}

View File

@@ -11,12 +11,6 @@ import java.util.List;
@Repository
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")
List<Vod> findByUser(User user);
}

View File

@@ -1,28 +0,0 @@
package com.ddf.vodsystem.security;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Collections;
@Configuration
public class GoogleAuthConfig {
/**
* Provides the verifier used to validate Google ID tokens issued for this application's client ID.
*
* @param googleClientId the OAuth client ID that tokens must be issued for
* @return the configured {@link GoogleIdTokenVerifier}
*/
@Bean
public GoogleIdTokenVerifier googleIdTokenVerifier(
@Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId) {
return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
.setAudience(Collections.singletonList(googleClientId))
.build();
}
}

View File

@@ -1,49 +0,0 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.GoogleUser;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
@Service
public class GoogleVerifierService {
private final GoogleIdTokenVerifier verifier;
public GoogleVerifierService(GoogleIdTokenVerifier verifier) {
this.verifier = verifier;
}
public GoogleUser verify(String idToken) {
GoogleIdToken token;
try {
token = verifier.verify(idToken);
} catch (GeneralSecurityException | IOException e) {
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
}
if (token == null) {
throw new NotAuthenticated("Invalid ID token");
}
String googleId = token.getPayload().getSubject();
if (googleId == null) {
throw new NotAuthenticated("Google ID does not exist");
}
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
throw new NotAuthenticated("Email not verified");
}
return new GoogleUser(
googleId,
token.getPayload().getEmail(),
(String) token.getPayload().get("name"),
(String) token.getPayload().get("picture")
);
}
}

View File

@@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
jwt = Arrays.stream(cookies)
.filter(cookie -> "accessToken".equals(cookie.getName()))
.filter(cookie -> "token".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);

View File

@@ -4,9 +4,11 @@ import java.util.Date;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service;
@Service
@@ -18,17 +20,12 @@ public class JwtService {
private static final String USER_ID_CLAIM = "userId";
private static final String ISSUER = "vodsystem";
public JwtService(AuthProperties props) {
this.jwtExpiration = props.expiration().jwt().toMillis();
this.algorithm = Algorithm.HMAC256(props.jwtSecret());
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
@Value("${jwt.expiration}") long jwtExpiration) {
this.jwtExpiration = jwtExpiration;
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
}
/**
* Generates a signed JWT carrying the given user ID, valid for the configured expiry.
*
* @param userId the user ID to embed as a claim
* @return the signed JWT string
*/
public String generateToken(Long userId) {
return JWT.create()
.withClaim(USER_ID_CLAIM, userId)
@@ -37,12 +34,6 @@ public class JwtService {
.sign(algorithm);
}
/**
* Verifies a JWT's signature, issuer, and expiry, returning the embedded user ID.
*
* @param token the JWT to validate
* @return the user ID claim if the token is valid and unexpired, otherwise {@code null}
*/
public Long validateTokenAndGetUserId(String token) {
try {
JWTVerifier verifier = JWT.require(algorithm)
@@ -57,7 +48,7 @@ public class JwtService {
}
return jwt.getClaim(USER_ID_CLAIM).asLong();
} catch (JWTVerificationException | IllegalArgumentException ignored) {
} catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) {
return null;
}
}

View File

@@ -1,248 +0,0 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.RefreshToken;
import com.ddf.vodsystem.entities.TokenFamily;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Optional;
/**
* Issues, rotates and revokes opaque refresh tokens.
* <p>
* Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it.
* Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace
* period is treated as theft and revokes the whole family.
*/
@Service
public class TokenService {
private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
private static final SecureRandom RNG = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
private final Clock clock;
private final TokenFamilyRepository tokenFamilyRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final Duration tokenFamilyExpiration;
private final Duration refreshTokenExpiration;
private final Duration refreshGracePeriod;
/**
* @param tokenFamilyRepository stores token families (one per login session)
* @param refreshTokenRepository stores hashed refresh tokens
* @param props supplies token family lifetime, refresh token lifetime and reuse grace period
* @param clock time source for all timestamps and expiry checks
*/
public TokenService (
TokenFamilyRepository tokenFamilyRepository,
RefreshTokenRepository refreshTokenRepository,
AuthProperties props,
Clock clock
) {
this.tokenFamilyRepository = tokenFamilyRepository;
this.refreshTokenRepository = refreshTokenRepository;
this.clock = clock;
this.refreshTokenExpiration = props.expiration().refreshToken();
this.tokenFamilyExpiration = props.expiration().tokenFamily();
this.refreshGracePeriod = props.expiration().gracePeriod();
}
/**
* Starts a new session for a user by creating a token family and its first refresh token.
*
* @param user the user the session belongs to
* @return the raw refresh token as a 64-character hex string; only its hash is persisted
*/
@Transactional
public String createSession(User user) {
Instant now = Instant.now(clock);
TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setUser(user);
tokenFamily.setCreatedAt(now);
tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
tokenFamilyRepository.save(tokenFamily);
byte[] token = generateRandomBytes(32);
createRefreshToken(
token,
tokenFamily
);
return HEX.formatHex(token);
}
/**
* Exchanges a refresh token for a new one in the same family.
* <p>
* The presented token is marked used and a new token is issued. Presenting a used token again
* within the grace period returns another new token without extending the grace period. Presenting
* it after the grace period revokes the family; this revocation is committed even though the
* method throws.
*
* @param hexToken the raw refresh token as a hex string
* @return the owning user's ID and the new raw refresh token
* @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked
* or expired family, or is reused after the grace period
*/
@Transactional(noRollbackFor = NotAuthenticated.class)
public Rotation rotate(String hexToken) {
if (hexToken == null || hexToken.isBlank()) {
throw new NotAuthenticated("Missing refresh token");
}
byte[] hash;
try {
hash = sha256(HEX.parseHex(hexToken));
} catch (IllegalArgumentException e) {
throw new NotAuthenticated("Malformed refresh token");
}
Instant now = Instant.now(clock);
// Check Token is real
RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
// Find Token Family
TokenFamily tokenFamily = refreshToken.getTokenFamily();
if (tokenFamily.getRevokedAt() != null) {
throw new NotAuthenticated("Token family has been revoked");
}
// Reuse Check
if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
tokenFamily.setRevokedAt(now);
tokenFamilyRepository.save(tokenFamily);
throw new NotAuthenticated("Refresh token already being used");
}
// Expiry Check
if (
now.isAfter(refreshToken.getExpiresAt()) ||
now.isAfter(tokenFamily.getExpiresAt())
) {
throw new NotAuthenticated("Refresh token or family expired");
}
// Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
if (refreshToken.getUsedAt() == null) {
refreshToken.setUsedAt(now);
refreshTokenRepository.save(refreshToken);
}
byte[] newRawToken = generateRandomBytes(32);
createRefreshToken(
newRawToken,
tokenFamily
);
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
}
/**
* Revokes a refresh token and its whole token family, ending the session. Does nothing if the
* token is missing or unknown, and leaves existing revocation timestamps unchanged.
*
* @param hexToken the raw refresh token as a hex string, may be {@code null}
* @throws NotAuthenticated if the token is not valid hex
*/
@Transactional
public void revoke(String hexToken) {
if (hexToken == null || hexToken.isBlank()) return;
byte[] hash;
try {
hash = sha256(HEX.parseHex(hexToken));
} catch (IllegalArgumentException e) {
throw new NotAuthenticated("Malformed refresh token");
}
Optional<RefreshToken> found = refreshTokenRepository.findByHash(hash);
if (found.isEmpty()) return;
Instant now = Instant.now(clock);
RefreshToken refreshToken = found.get();
TokenFamily family = refreshToken.getTokenFamily();
if (family.getRevokedAt() == null) {
family.setRevokedAt(now);
tokenFamilyRepository.save(family);
}
if (refreshToken.getRevokedAt() == null) {
refreshToken.setRevokedAt(now);
refreshTokenRepository.save(refreshToken);
}
}
/**
* Scheduled cleanup that deletes token families that can no longer be refreshed, along with all
* their refresh tokens.
* <p>
* Runs hourly. Used tokens in live families are kept, since reuse detection needs them.
*/
@Scheduled(fixedDelay = 3_600_000)
@Transactional
public void deleteDeadSessions() {
int deleted = tokenFamilyRepository.deleteDead(Instant.now(clock));
if (deleted > 0) logger.info("Deleted {} dead token families", deleted);
}
/**
* Result of a successful {@link #rotate(String)}.
*
* @param userId ID of the user who owns the token family
* @param newRefreshToken the newly issued raw refresh token as a hex string
*/
public record Rotation(Long userId, String newRefreshToken) {}
private static byte[] sha256(byte[] in) {
try {
return MessageDigest.getInstance("SHA-256").digest(in);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private byte[] generateRandomBytes(int length) {
byte[] bytes = new byte[length];
RNG.nextBytes(bytes);
return bytes;
}
private RefreshToken createRefreshToken(
byte[] token,
TokenFamily tokenFamily
) {
Instant now = Instant.now(clock);
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setTokenHash(sha256(token));
refreshToken.setCreatedAt(now);
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
return refreshTokenRepository.save(refreshToken);
}
}

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