6 Commits

Author SHA1 Message Date
beff49a3ef rename Box to Card
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 22:21:45 +01:00
3c50d6aaf4 add cringe home message
All checks were successful
CI / Maven unit tests (push) Successful in 20s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:28:34 +01:00
9dcab44465 add titles to homepage
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:23:34 +01:00
6ddffd8e2b fix sidebar spacing issues
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:53:13 +01:00
c86e5e5568 add colour and remove topbar
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:20:26 +01:00
2f9ddadb91 add new fonts
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 19:14:20 +01:00
82 changed files with 1673 additions and 1467 deletions

View File

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

View File

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

View File

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

View File

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

138
ethos.md Normal file
View File

@@ -0,0 +1,138 @@
# The VoD System — Frontend Design Reference
Warm, editorial, low-key. Soft shadows instead of borders, cream surfaces, one terracotta and one olive accent. No dark-dashboard chrome.
---
## 1. Typography
| Role | Family | Usage |
| --- | --- | --- |
| Display / headings | **Newsreader** (serif, weight 400) | Page titles, card headings, item titles, greeting, logo wordmark. Always regular weight — never bold. |
| UI / body | **IBM Plex Sans** (400, 500) | Nav, labels, buttons, body copy, table cells. 500 only for the active nav item. |
| Data / mono | **IBM Plex Mono** (400, 500) | Timecodes, stream key, ingest URL, durations, bitrates, file facts, kickers, job stages. |
Fallbacks: `system-ui, sans-serif` for Plex Sans; `serif` for Newsreader; `monospace` for Plex Mono.
### Type scale
| Element | Size | Family | Notes |
| --- | --- | --- | --- |
| Greeting (dashboard h1) | 40px | Newsreader | `letter-spacing: -0.015em` |
| Page h1 | 36px | Newsreader | -0.015em |
| Detail title | 31px | Newsreader | -0.015em |
| Drop-zone headline | 25px | Newsreader | |
| Section h2 | 23px | Newsreader | |
| Logo wordmark | 22px | Newsreader | -0.01em |
| Card heading (in-panel) | 20–22px | Newsreader | |
| Status headline | 27px | Newsreader | line-height 1.25 |
| VOD tile title | 17.5–18px | Newsreader | line-height 1.3 |
| Big metric | 21px | Plex Mono | elapsed, marker count, bitrate |
| Range length | 15px | Plex Mono | |
| Body paragraph | 14.5px | Plex Sans | line-height 1.65, `text-wrap: pretty`, max 62ch |
| Primary button | 13.5–14px | Plex Sans | |
| Field value | 13.5–14px | Plex Sans / Mono | mono for timecodes and sizes |
| Table cell | 13.5px | Plex Sans | mono for numeric columns |
| Clip tile title | 13.5px | Plex Sans | |
| Meta line | 12–13px | Plex Sans | muted ink |
| Field label | 11.5px | Plex Sans | muted ink |
| Kicker / eyebrow | 10.5–11px | Plex Mono | `letter-spacing: 0.10–0.14em`, uppercase |
| Ruler / tick caption | 10–10.5px | Plex Mono | |
---
## 2. Colour
### Surfaces
| Token | Hex | Where |
| --- | --- | --- |
| Canvas | `#F7F4EE` | Page background |
| Sidebar | `#F2EEE6` | Nav column and collapsed rail |
| Card | `#FDFBF7` | Cards, library tiles, active nav pill |
| Field well | `#F4F0E8` | Inputs, credential wells, timeline track |
| Muted panel | `#F1ECE3` / `#F1EDE4` | Secondary buttons, "how it works", clip-cutter panel |
| Drop zone (idle) | `#F9F6EF` | Upload empty state |
| Armed / selected | `#F6EFE6` | Selected marker row, filled range card |
| Live card | `#F0EBE1` | Dashboard status block when live |
| Player body | `#2B2620` | Video chrome, also the dark button |
| Player stripes | `#3A342C` / `#332E27` | Placeholder frame texture |
### Ink
| Token | Hex | Where |
| --- | --- | --- |
| Primary | `#2B2620` | Headings, metrics |
| Strong | `#35302A` | Field values, tile titles |
| Body | `#554D44` | Paragraph copy |
| Control label | `#4A423A` | Secondary button text |
| Secondary | `#6E6559` | Supporting copy |
| Table value | `#7A7166` | History rows |
| Muted | `#9A9084` | Labels, meta, kickers |
| Faint | `#A79C8E` | Mono captions, ruler ticks, hints |
| On dark | `#FBF8F2` | Text on player / dark button |
| On accent | `#FDFBF7` | Text on terracotta / olive |
### Accents
| Token | Hex | Where |
| --- | --- | --- |
| Terracotta | `#A85A38` | Logo mark, primary CTA, links, marker timecodes, playheads |
| Terracotta hover | `#94492A` | Primary button hover |
| Terracotta light | `#C87A52` | Progress bars, scrubber fill, selected marker ring |
| Terracotta disabled | `#C9AE9C` | "Waiting for a file" button |
| Olive | `#6E7A50` | Clip actions, trim in/out handles |
| Olive hover | `#5D6843` | |
| Olive light | `#8A9668` | Completed job bar |
| Live red | `#B5432E` | Live dot and "LIVE NOW" label |
### Lines, chips, placeholders
| Token | Hex | Where |
| --- | --- | --- |
| Hairline | `#F0EBE2` | Row dividers (1px) |
| Chip fill | `#EAE3D7`, `#EFE9DE`, `#E4DDD0` | Progress track, toggles, nav-hide button |
| Chip hover | `#E7E0D2`, `#DAD1C0`, `#EAE4D9` | |
| Drop-zone ring | `#E2DACB` | `inset 0 0 0 1.5px` |
| Inactive marks | `#DED7CA`, `#C4BAA9` | Unchecked boxes, unselected ticks |
| Zoom window outline | `#8E8577` | Overview strip frame |
| VOD thumbnail | `#E3DACB` / `#DAD0BF` | 135° stripes, 9px |
| Clip thumbnail | `#DFE0D2` / `#D6D8C7` | 135° stripes, 9px |
| Avatar | `#D9C9AE` on `#5C4A32` | Google account chip |
---
## 3. Elevation & shape
- **Card**: `0 1px 2px rgba(60,48,32,.05), 0 10px 26px -18px rgba(60,48,32,.35)`
- **Card hover**: `0 2px 4px rgba(60,48,32,.07), 0 18px 34px -18px rgba(60,48,32,.45)`
- **Panel (heavier)**: `0 1px 2px rgba(60,48,32,.05), 0 16px 36px -22px rgba(60,48,32,.3)`
- **Player**: `0 1px 2px rgba(60,48,32,.08), 0 22px 46px -26px rgba(60,48,32,.5)`
- **Terracotta button**: `0 1px 2px rgba(90,40,20,.2), 0 12px 22px -14px rgba(90,40,20,.55)`
- **Olive button**: `0 1px 2px rgba(50,60,30,.18), 0 10px 20px -14px rgba(50,60,30,.55)`
- **Inset ring** (used instead of borders): `inset 0 0 0 1px rgba(60,48,32,.08)`
Radii: 16px hero/status blocks · 15px panels · 13px tiles · 12px thumbnails/player · 10px inputs & dark buttons · 9px buttons, nav pills, logo mark · 8px chips and fields · 5–6px overlay badges · 50% avatars and play button.
Media ratios: 16/9 VOD thumbnails and player · 4/3 clip thumbnails · 16/7 upload drop zone.
---
## 4. Layout
- Shell: `252px` sidebar + fluid main; collapsed rail is `62px`.
- Main padding `40px 52px 72px`, content max-width `1060–1180px`.
- Library grids: 3 columns for VODs (gap 22px), 4 for clips (gap 18–20px).
- Detail and stream pages: content column + `330–340px` sticky side panel, gap 26px.
- Spacing rhythm: 26–34px between sections, 14–20px inside cards, 6–10px label-to-field. Always flex/grid with `gap`.
---
## 5. Motion & state
- `livePulse` — 2.2s ease-in-out infinite opacity/scale pulse, live dot only. Nothing else animates decoratively.
- Hover: background shifts one step darker within the same family; cards deepen their shadow. No transforms.
- Selection: `#F6EFE6` fill plus a `1.5px` terracotta inset ring.
- Progress is always a bar with a stage word and remaining time (`queued → reading source → encoding → writing file → complete`) — never a spinner.
---
## 6. Copy tone
Lowercase mono for machine facts, sentence case for everything else. Personal and specific ("Cat walks across the patch bay"), never marketing voice. Durations as `h:mm:ss`, timecodes as `hh:mm:ss`, sizes in MB/GB.

View File

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

View File

@@ -1,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

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

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,18 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"postinstall": "flowbite-react patch"
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"axios": "^1.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.5.0",
"flowbite-react": "^0.11.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"path": "^0.12.7",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1", "react-range-slider-input": "^3.2.1",
@@ -33,6 +39,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5",
"vite-plugin-mkcert": "^1.17.8"
} }
} }

1
frontend/public/vite.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export { AuthContext };
export type { AuthContextValue };

View File

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

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

View File

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

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li <li
className={clsx("cursor-pointer hover:bg-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)} onClick={() => onClick(item)}
> >
{item} {item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}> <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button <button
onClick={() => setIsOpen(v => !v)} onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 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} {label}
</button> </button>
{isOpen && ( {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} {children}
</ul> </ul>
)} )}

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react'; import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import { useAuth } from "../auth/useAuth.ts"; import {useAuth} from "../utils/AuthContext.tsx";
import {type CredentialResponse, GoogleLogin} from "@react-oauth/google";
import {getUser, login, logout} from "../utils/api/users.ts";
type props = { type props = {
className?: string className?: string
@@ -9,41 +11,94 @@ type props = {
const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />; const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({className}: props) => { const Sidebar = ({ className }: props) => {
const { user } = useAuth(); const { user, setUser } = useAuth()
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
getUser().then(userData => setUser(userData))
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
return ( return (
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}> <aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<h2 className={"font-heading text-2xl mt-1"}>The VoD System</h2> <h2 className={"font-heading text-3xl"}>The VoD System</h2>
<nav className="flex flex-col flex-1 items-center w-3/4"> <nav className="flex flex-col flex-1 items-center">
<Divider /> <Divider />
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" /> <SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" /> <SidebarButton url="/create" logo={<Plus size={18}/>} label="Upload" />
</div> </div>
<Divider /> <Divider />
{user && ( {user && (
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<div> <div>
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" /> <SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" /> <SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div>
</div> </div>
</div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<div className={"w-full"}> <div className={"p-2"}>
{user && ( {user ? (
<> <div>
<Divider /> <Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" /> <div className={"flex gap-2 items-center pt-1"}>
</> <img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt={"Profile Picture"}
/>
<label className={"font-body text-text-strong text-sm"}>
{user.name}
</label>
<button
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
) : (
<div>
<Divider />
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={
(credentialResponse) => handleLogin(credentialResponse
)}
/>
</div>
)} )}
</div> </div>
</nav> </nav>

View File

@@ -1,92 +0,0 @@
import { Menu, X } from 'lucide-react';
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { useAuth } from "../auth/useAuth.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const { user, login, logout } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() => navigate("/"))
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div className={"hover:bg-hover rounded-lg p-0.5"}>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
/>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-error font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
)
}
export default Topbar;

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

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

View File

@@ -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

@@ -16,9 +16,9 @@ const SidebarButton = ({url, logo, label}: Props) => {
<button className={`flex items-center gap-2.5 w-full text-sm <button className={`flex items-center gap-2.5 w-full text-sm
font-body p-2 hover:bg-hover hover:text-text-strong font-body p-2 hover:bg-hover hover:text-text-strong
rounded-md rounded-md
${isActive ? ${isActive ?
"bg-card text-text-strong font-medium shadow-sm" "bg-card text-text-strong font-medium shadow-sm"
: :
"text-text-primary"}`}> "text-text-primary"}`}>
{logo}{label} {logo}{label}
</button> </button>
@@ -26,4 +26,4 @@ const SidebarButton = ({url, logo, label}: Props) => {
) )
} }
export default SidebarButton; export default SidebarButton;

View File

@@ -8,7 +8,7 @@ type Props = {
className?: string; 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) { export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -26,7 +26,7 @@ export default function ConfigBox({ setMetadata, className }: Props) {
return ( return (
<div className={clsx("flex flex-col gap-3 p-6", className)}> <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"> <Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720" <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"; import React from "react";
type props = { type props = {
@@ -24,24 +24,24 @@ const Spinner = () => (
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => { const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return ( return (
<div className={"flex flex-col gap-3"}> <div className={"flex flex-col gap-3"}>
<PrimaryButton <BlueButton
onClick={dataSend} onClick={dataSend}
disabled={uploading} disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""} className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
> >
{uploading && <Spinner />} {uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"} {uploading ? "Uploading..." : "Export"}
</PrimaryButton> </BlueButton>
<div className="h-10 flex items-center"> <div className="h-10 flex items-center">
{ downloadable ? { downloadable ?
(<PrimaryButton className="w-full" onClick={handleDownload}> (<BlueButton className="w-full" onClick={handleDownload}>
Download Download
</PrimaryButton>) </BlueButton>)
:( :(
<progress <progress
value={progress} value={progress}
className="w-full h-1.5 rounded bg-fields"> className="w-full h-1.5 rounded bg-gray-200">
</progress> </progress>
) )
} }

View File

@@ -9,15 +9,12 @@ type Props = {
const MetadataBox = ({ setMetadata, className }: Props) => ( const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}> <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"> <p className="w-full font-semibold text-gray-800 text-sm">Title</p>
Title <span className="text-error">*</span>
</p>
<input <input
type="text" type="text"
required
placeholder="Enter title" placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))} 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> </div>
); );

View File

@@ -62,11 +62,11 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
</div> </div>
<button <button
onClick={togglePlay} 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} />} {isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button> </button>
<span className="font-data text-sm text-text-secondary tabular-nums"> <span>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span> </span>
</div> </div>

View File

@@ -1,9 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import 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 { MoreVertical, Pencil, Trash2 } from "lucide-react";
import {config} from "../../config.ts";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
@@ -18,12 +18,12 @@ type VideoCardProps = {
itemLabel?: string, itemLabel?: string,
} }
const fallbackThumbnail = "public/default_thumbnail.png"; const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = config.apiUrl; 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 VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [imgFailed, setImgFailed] = useState(false); const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -36,6 +36,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
return () => clearInterval(interval); return () => clearInterval(interval);
}, [createdAt]); }, [createdAt]);
useEffect(() => {
isThumbnailAvailable(thumbnailUrl)
.then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false));
}, [thumbnailUrl]);
useEffect(() => { useEffect(() => {
if (!menuOpen) return; if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => { 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={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg"> <div className="relative overflow-hidden rounded-lg">
<img <img
src={imgFailed ? fallbackThumbnail : thumbnailUrl} src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
onError={() => setImgFailed(true)}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" 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-text-primary/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)} {formatTime(duration)}
</span> </span>
@@ -77,18 +82,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
> >
<button <button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })} onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-text-primary/75 hover:bg-text-primary/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} /> <MoreVertical size={14} />
</button> </button>
{menuOpen && ( {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 && (<> {!confirmDelete && (<>
{onEdit && ( {onEdit && (
<button <button
onClick={(e) => stopAndRun(e, onEdit)} onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 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 <Pencil size={13} /> Edit
</button> </button>
@@ -96,7 +101,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && ( {onDelete && (
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 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 <Trash2 size={13} /> Delete
</button> </button>
@@ -104,17 +109,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <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"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} 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 Delete
</button> </button>
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))} 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 Cancel
</button> </button>
@@ -128,10 +133,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</div> </div>
<div className="flex flex-col p-2"> <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} {title === "" ? "(No Title)" : title}
</p> </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>
</div> </div>
</Link> </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,51 +1,31 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@theme { @theme {
/* ── Fonts ───────────────────────────────────────────────────────────── */ /* Fonts */
--font-body: "IBM Plex Sans", system-ui, sans-serif; /*--font-heading: "Newsreader", serif; */
--font-body: "IBM Plex Sans", sans-serif;
--font-data: "IBM Plex Mono", monospace; --font-data: "IBM Plex Mono", monospace;
/* ── Surfaces ────────────────────────────────────────────────────────── */ /* Colours */
--color-background: #F7F4EE; --color-background: #F7F4EE;
--color-sidebar: #F2EEE6; --color-sidebar: #F2EEE6;
--color-card: #FDFBF7; --color-card: #FDFBF7;
--color-fields: #F4F0E8; --color-accent: #A85A38;
--color-dropzone: #F9F6EF; --color-accent-hover: #94492A;
--color-live-card: #F0EBE1;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* Colour Fonts */
--color-text-primary: #2B2620; --color-text-primary: #2B2620;
--color-text-strong: #35302A; --color-text-strong: #35302A;
--color-text-body: #554D44;
--color-text-secondary: #6E6559;
--color-muted: #9A9084;
--color-faint-mono: #A79C8E; --color-faint-mono: #A79C8E;
--color-muted: #9A9084;
/* ── 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-hover: #E7E0D2;
--color-selected: #F6EFE6; --color-hairline: #F0EBE2;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
} }
:root { :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; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
@@ -56,35 +36,10 @@
body { body {
background-color: var(--color-background); background-color: var(--color-background);
font-family: "IBM Plex Sans", system-ui, sans-serif; font-family: "IBM Plex Sans", sans-serif;
color: var(--color-text-body);
} }
@utility font-heading { @utility font-heading {
font-family: "Newsreader", serif; font-family: "Newsreader", serif;
font-weight: 400; font-weight: 400;
color: var(--color-text-primary);
}
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {
height: 8px;
background: var(--color-fields);
border-radius: 8px;
}
#range-slider .range-slider__range {
background: var(--color-terracotta-light);
}
#range-slider .range-slider__thumb {
width: 18px;
height: 18px;
background: var(--color-terracotta);
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
}
#range-slider .range-slider__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
} }

View File

@@ -1,37 +1,19 @@
// layout/MainLayout.jsx // layout/MainLayout.jsx
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react"; import { useState } from "react";
import { useAuth } from "../auth/useAuth";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false); const [sidebarToggled] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[280px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar <div>
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
); );
}; };
export default MainLayout; export default MainLayout;

View File

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

View File

@@ -8,11 +8,10 @@ import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media" import { compress } from "../utils/api/media"
import { getJob } from "../utils/api/jobs" import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
import {config} from "../config.ts";
const API_URL = config.apiUrl; const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => { const ClipEdit = () => {
const location = useLocation(); const location = useLocation();
@@ -64,18 +63,12 @@ const ClipEdit = () => {
const sendData = async () => { const sendData = async () => {
if (!localFile || uploading) return; if (!localFile || uploading) return;
const title = outputMetadata.title.trim();
if (!title) {
setError('A title is required before exporting.');
return;
}
setUploading(true); setUploading(true);
setError(null); setError(null);
let jobId: string; let jobId: string;
try { try {
jobId = await compress(localFile, { ...outputMetadata, title }); jobId = await compress(localFile, outputMetadata);
setUploadedId(jobId); setUploadedId(jobId);
} catch (err: unknown) { } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -86,7 +79,7 @@ const ClipEdit = () => {
const interval = setInterval(async () => await pollProgress(jobId, interval), 500); 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) getJob(jobId)
.then((job) => { .then((job) => {
setProgress(job.progress); setProgress(job.progress);
@@ -135,21 +128,21 @@ const ClipEdit = () => {
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-text-primary"} className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded} onLoadedMetadata={handleVideoMetadataLoaded}
/> />
<Box className={"w-4/5 h-full m-auto"}> <Card className={"w-4/5 h-full m-auto"}>
<MetadataBox setMetadata={setOutputMetadata} /> <MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} /> <ConfigBox setMetadata={setOutputMetadata} />
</Box> </Card>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Card className={"mt-4 p-5"}>
<Playbar <Playbar
video={videoRef.current} video={videoRef.current}
videoMetadata={metadata} videoMetadata={metadata}
className={"w-full accent-terracotta"} className={"w-full accent-primary"}
/> />
<PlaybackSlider <PlaybackSlider
@@ -157,7 +150,7 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
sliderValue={playbackValue} sliderValue={playbackValue}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
className={"w-full accent-terracotta"} className={"w-full accent-primary"}
/> />
<ClipRangeSlider <ClipRangeSlider
@@ -165,18 +158,18 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata} setMetadata={setOutputMetadata}
className={"w-full mt-2"} className={"w-full mt-2 bg-primary"}
/> />
{error && ( {error && (
<div className={"text-error text-center mt-2"}> <div className={"text-red-600 text-center mt-2"}>
{error} {error}
</div> </div>
)} )}
</Box> </Card>
} }
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}> <Card className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
<ExportWidget <ExportWidget
dataSend={sendData} dataSend={sendData}
handleDownload={handleDownload} handleDownload={handleDownload}
@@ -184,7 +177,7 @@ const ClipEdit = () => {
progress={progress} progress={progress}
uploading={uploading} uploading={uploading}
/> />
</Box> </Card>
</div> </div>
); );
}; };

View File

@@ -1,11 +1,12 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import Box from "../components/Box.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Card from "../components/Card.tsx";
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const press = () => { const press = () => {
if (!file) { if (!file) {
@@ -16,41 +17,24 @@ const ClipUpload = () => {
}; };
return ( 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"> <Card className="flex flex-col gap-4 p-6 w-full max-w-md">
<div <h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
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>
<button <input
onClick={press} type="file"
className={"hover:bg-hover p-1 rounded-md"} onChange={(e) => {
> const selected = e.target.files?.[0] ?? null;
Upload setFile(selected);
</button> 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>
</Box>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Card>
</div> </div>
) )
}; };

View File

@@ -2,10 +2,10 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/api/clips.ts"; import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.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 EditClip = () => {
const { id } = useParams(); const { id } = useParams();
@@ -50,20 +50,20 @@ const EditClip = () => {
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="px-8 py-10 max-w-2xl mx-auto">
<button <button
onClick={() => navigate(-1)} 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} /> <ArrowLeft size={16} />
Back Back
</button> </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 ? ( {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"> <Card className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <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 <input
type="text" type="text"
value={title} value={title}
@@ -74,7 +74,7 @@ const EditClip = () => {
</div> </div>
<div className="flex flex-col gap-1.5"> <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 <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
@@ -84,22 +84,22 @@ const EditClip = () => {
/> />
</div> </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"> <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 {saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> ? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"} : "Save"}
</PrimaryButton> </BlueButton>
<button <button
onClick={() => navigate(-1)} 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 Cancel
</button> </button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -2,10 +2,10 @@ import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft, Check } from "lucide-react";
import { getVodById, patchVod } from "../utils/api/vods.ts"; import { getVodById, patchVod } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.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 EditVod = () => {
const { id } = useParams(); const { id } = useParams();
@@ -50,20 +50,20 @@ const EditVod = () => {
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="px-8 py-10 max-w-2xl mx-auto">
<button <button
onClick={() => navigate(-1)} 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} /> <ArrowLeft size={16} />
Back Back
</button> </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 ? ( {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"> <Card className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5"> <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 <input
type="text" type="text"
value={title} value={title}
@@ -74,7 +74,7 @@ const EditVod = () => {
</div> </div>
<div className="flex flex-col gap-1.5"> <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 <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
@@ -84,22 +84,22 @@ const EditVod = () => {
/> />
</div> </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"> <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 {saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> ? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"} : "Save"}
</PrimaryButton> </BlueButton>
<button <button
onClick={() => navigate(-1)} 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 Cancel
</button> </button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,78 +1,84 @@
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react"; import {useAuth} from "../utils/AuthContext.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import {Link} from "react-router-dom";
import Card from "../components/Card.tsx";
const features = [ type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
{
icon: Upload, function getTimeOfDay(date: Date = new Date()): TimeOfDay {
title: "Upload & compress", const hour = date.getHours();
description: "Drop in a video file, set your clip range and export settings, then compress.",
}, if (hour >= 5 && hour < 12) return "morning";
{ if (hour >= 12 && hour < 17) return "afternoon";
icon: Scissors, if (hour >= 17 && hour < 22) return "evening";
title: "Trim", return "night";
description: "Set start and end points with a range slider. Preview as you go.", }
},
{ function getGreetingSubtext(date: Date = new Date()): string {
icon: SlidersHorizontal, const timeOfDay = getTimeOfDay(date);
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.", const messages: Record<TimeOfDay, string[]> = {
}, morning: [
{ "Good morning.",
icon: Radio, "Coffee first, then clips.",
title: "RTMP streaming", "Here's what's waiting for you.",
description: "Point OBS at your stream key and go live. Find the key in your profile.", ],
}, afternoon: [
{ "Good afternoon.",
icon: LayoutGrid, "A quiet moment for your clips.",
title: "Clip library", "Catching up?",
description: "Browse all your clips. Watch, rename, edit the description, or delete.", ],
}, evening: [
{ "Good evening.",
icon: Play, "Settle in.",
title: "Built-in player", "Time to unwind.",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.", ],
}, night: [
]; "Still up?",
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
const Home = () => { const Home = () => {
const { user } = useAuth();
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
if (user == null) {
return (<NoAccessPage />)
}
return ( return (
<div className="flex flex-col items-center px-8 py-12 min-h-full"> <div className={"w-10/12 m-auto pt-10"}>
{/* Hero */} <div className={"flex flex-row justify-between items-start pb-10"}>
<div className="flex flex-col items-center text-center max-w-2xl mb-14"> <div>
<img <p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
src="/logo.png" <h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{getGreetingSubtext(new Date())}</h1>
alt="VoD System Logo" </div>
className="h-24 mb-6 object-contain"
/> <Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
<h1 className="text-4xl font-heading text-text-primary"> Open Stream Panel
A home for your clips. </Link>
</h1>
<p className="text-text-secondary">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div> </div>
{/* Feature grid */} <Card className={"bg-live-card pb-10"}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10"> <p></p>
{features.map(({ icon: Icon, title, description }) => ( </Card>
<div
key={title} <div>
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm" <h2 className={"font-heading text-2xl"}>Recent VoDs</h2>
>
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
<Icon size={18} className="text-terracotta" />
</div>
<div>
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
</div>
</div>
))}
</div> </div>
{/* Format note */}
<p className="text-xs text-muted">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import { AuthError } from "../utils/api/client.ts"; import { AuthError } from "../utils/api/client.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// Both VoDs and clips share the same on-screen shape. // Both VoDs and clips share the same on-screen shape.
@@ -202,16 +202,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (notAuthenticated) { if (notAuthenticated) {
return ( return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8"> <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"> <div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
<Lock size={24} className="text-muted" /> <Lock size={24} className="text-gray-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-heading text-text-primary">Not authenticated</p> <p className="text-lg font-semibold text-gray-900">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-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div> </div>
<button <button
onClick={() => navigate(-1)} 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} /> <ArrowLeft size={16} />
Go back Go back
@@ -224,7 +224,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
<div className="h-full flex flex-col p-4 gap-3"> <div className="h-full flex flex-col p-4 gap-3">
<button <button
onClick={() => navigate(-1)} 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} /> <ArrowLeft size={16} />
Back Back
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Player */} {/* Player */}
<div <div
ref={containerRef} ref={containerRef}
className="relative bg-text-primary 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} onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)} onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay} onClick={togglePlay}
@@ -265,7 +265,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Buffering spinner */} {/* Buffering spinner */}
{isWaiting && mediaUrl && ( {isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-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> </div>
)} )}
@@ -282,20 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Scrub bar */} {/* Scrub bar */}
<div <div
ref={progressRef} ref={progressRef}
className="relative h-1 rounded-full bg-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); }} 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-white/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-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div <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)` }} style={{ left: `calc(${progressPct}% - 6px)` }}
/> />
</div> </div>
{/* Buttons row */} {/* Buttons row */}
<div className="flex items-center gap-3 text-on-dark"> <div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-on-dark/70 transition-colors"> <button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying {isPlaying
? <Pause size={20} fill="currentColor" /> ? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />} : <Play size={20} fill="currentColor" />}
@@ -303,7 +303,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Volume — slider expands on hover */} {/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol"> <div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-on-dark/70 transition-colors"> <button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} /> <VolumeIcon size={18} />
</button> </button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200"> <div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
@@ -312,18 +312,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
min={0} max={1} step={0.02} min={0} max={1} step={0.02}
value={effectiveVolume} value={effectiveVolume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
className="w-16 accent-on-dark cursor-pointer" className="w-16 accent-white cursor-pointer"
/> />
</div> </div>
</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)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
<div className="flex-1" /> <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} />} {isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button> </button>
</div> </div>
@@ -331,18 +331,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</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 && ( {!mediaLoaded && !error && (
<div className="text-muted text-sm mt-3 flex items-center gap-2"> <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-inactive border-t-muted animate-spin" /> <div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video... Loading video...
</div> </div>
)} )}
<Box className="p-4 flex-shrink-0"> <Card className="p-4 flex-shrink-0">
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p> <p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p> <p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box> </Card>
</div> </div>
); );
}; };

View File

@@ -26,7 +26,7 @@ const MyClips = () => {
return ( return (
<div className="p-6"> <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>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
@@ -43,13 +43,13 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <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-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="text-sm mt-1">Upload your first clip to get started.</p>
</div> </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> </div>
); );
} }

View File

@@ -26,7 +26,7 @@ const MyVods = () => {
return ( return (
<div className="p-6"> <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>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => ( {vods.map((vod) => (
@@ -46,13 +46,13 @@ const MyVods = () => {
</div> </div>
{vods.length === 0 && !error && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <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-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div> </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> </div>
); );
}; };

View File

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

View File

@@ -1,18 +1,18 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Radio, Check, Bookmark, 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 clsx from "clsx";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import PrimaryButton from "../components/buttons/PrimaryButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import { useAuth } from "../auth/useAuth.ts"; import { getUser } from "../utils/api/users.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts"; import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"; import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts"; import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts"; import { getJob } from "../utils/api/jobs.ts";
import type { StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts"; import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.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 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 = "font-data text-[11px] text-muted uppercase tracking-[0.12em]"; const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
type TrackedJob = { type TrackedJob = {
uuid: string; uuid: string;
@@ -24,10 +24,10 @@ type TrackedJob = {
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => ( 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="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-terracotta/10" : "bg-olive/10")}> <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-terracotta" : "text-olive"} /> <Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
</div> </div>
<h2 className="text-xl font-heading text-text-primary">{title}</h2> <h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div> </div>
); );
@@ -35,32 +35,32 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') { if (job.state === 'FAILED') {
return ( return (
<div className="flex items-start gap-2 text-sm"> <div className="flex items-start gap-2 text-sm">
<X size={15} className="text-error mt-0.5 flex-shrink-0" /> <X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-error">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p> <p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div> </div>
); );
} }
if (job.state === 'SUCCEEDED') { if (job.state === 'SUCCEEDED') {
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-olive flex-shrink-0" /> <Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-text-body">{job.label} saved</p> <p className="text-gray-700">{job.label} saved</p>
</div> </div>
); );
} }
return ( return (
<div className="flex items-center gap-2"> <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"> <div className="flex-1">
<p className="text-sm text-text-secondary mb-1">{job.label}…</p> <p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" /> <progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
</div> </div>
</div> </div>
); );
}; };
const StreamControl = () => { const StreamControl = () => {
const { user } = useAuth(); const [user, setUser] = useState<User | null>(null);
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null); const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null); const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
@@ -85,6 +85,10 @@ const StreamControl = () => {
const isStreaming = streamStatus?.isStreaming ?? false; const isStreaming = streamStatus?.isStreaming ?? false;
useEffect(() => {
getUser().then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -222,7 +226,7 @@ const StreamControl = () => {
if (!user) { if (!user) {
return ( 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. Please log in to control your stream.
</div> </div>
); );
@@ -237,44 +241,44 @@ const StreamControl = () => {
return ( return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5"> <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 */} {/* Hero status bar */}
<Box className={clsx( <Card className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4", "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"> <div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-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" />} {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-live" : "bg-inactive-strong")} /> <span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
</span> </span>
<div> <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 ? ( {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 ? ( ) : !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} ) : null}
</div> </div>
</div> </div>
{isStreaming && elapsed ? ( {isStreaming && elapsed ? (
<div className="text-right"> <div className="text-right">
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p> <p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className={labelClass}>elapsed</p> <p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
</div> </div>
) : ( ) : (
<Radio size={28} className="text-inactive" /> <Radio size={28} className="text-gray-200" />
)} )}
</Box> </Card>
{isStreaming && ( {isStreaming && (
<> <>
{/* Quick mark toolbar */} {/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3"> <Card className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Bookmark size={18} className="text-olive flex-shrink-0" /> <Bookmark size={18} className="text-accent flex-shrink-0" />
<input <input
type="text" type="text"
value={markerMessage} value={markerMessage}
@@ -283,11 +287,11 @@ const StreamControl = () => {
placeholder="What just happened?" placeholder="What just happened?"
className={inputClass} className={inputClass}
/> />
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap"> <BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker Add Marker
</PrimaryButton> </BlueButton>
</div> </div>
{markerError && <p className="text-sm text-error">{markerError}</p>} {markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markers.length > 0 && ( {markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5"> <div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
@@ -300,22 +304,22 @@ const StreamControl = () => {
onClick={() => handleChipClick(m.id)} onClick={() => handleChipClick(m.id)}
className={clsx( className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150", "flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-terracotta bg-selected ring-1 ring-terracotta", isStart && "border-primary bg-primary/5 ring-1 ring-primary",
isEnd && "border-olive bg-selected ring-1 ring-olive", isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
!isStart && !isEnd && "border-hairline hover:border-ring hover:bg-hover" !isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
)} )}
> >
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span> <span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
<span className="font-data text-[11px] text-muted"> <span className="text-[11px] text-gray-400">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })} {formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-terracotta font-medium"> · start</span>} {isStart && <span className="text-primary font-medium"> · start</span>}
{isEnd && <span className="text-olive font-medium"> · end</span>} {isEnd && <span className="text-accent font-medium"> · end</span>}
</span> </span>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }} onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
title="Delete marker" 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-muted hover:bg-error text-on-accent transition-colors duration-150" 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} /> <X size={10} />
</button> </button>
@@ -324,11 +328,11 @@ const StreamControl = () => {
})} })}
</div> </div>
)} )}
</Box> </Card>
{/* Forefront actions */} {/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Box className="p-6 flex flex-col gap-3"> <Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" /> <CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className={labelClass}>Duration (seconds)</label> <label className={labelClass}>Duration (seconds)</label>
@@ -342,50 +346,50 @@ const StreamControl = () => {
</div> </div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} /> <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"} /> <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>} {clipError && <p className="text-sm text-red-500">{clipError}</p>}
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton> <BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Box> </Card>
<Box className="p-6 flex flex-col gap-3"> <Card className="p-6 flex flex-col gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" /> <CardHeader icon={Film} title="Save between two markers" accent="accent" />
{markers.length < 2 ? ( {markers.length < 2 ? (
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p> <p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
) : ( ) : (
<> <>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className={clsx( <span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border", "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 ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
)}> )}>
{startMarker ? startMarker.message : "Tap a marker to set start"} {startMarker ? startMarker.message : "Tap a marker to set start"}
</span> </span>
<span className="text-inactive-strong">→</span> <span className="text-gray-300">→</span>
<span className={clsx( <span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border", "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 ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
)}> )}>
{endMarker ? endMarker.message : "Tap a marker to set end"} {endMarker ? endMarker.message : "Tap a marker to set end"}
</span> </span>
</div> </div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} /> <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"} /> <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>} {markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
</> </>
)} )}
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton> <BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
</Box> </Card>
</div> </div>
<Box className="p-5 flex flex-col gap-3"> <Card className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" /> <CardHeader icon={Activity} title="Recent actions" />
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<p className="text-sm text-muted">Clip and save actions will show up here.</p> <p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)} {jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div> </div>
)} )}
</Box> </Card>
</> </>
)} )}
</div> </div>

View File

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

View File

@@ -1,6 +1,4 @@
import {config} from "../../config.ts"; const API_URL = import.meta.env.VITE_API_URL;
const API_URL = config.apiUrl;
class AuthError extends Error { class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; } constructor() { super("Not authenticated"); this.name = "AuthError"; }

View File

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

View File

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

View File

@@ -10,11 +10,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableAsync @EnableAsync
public class VodSystemApplication { public class VodSystemApplication {
/**
* Application entry point; boots the Spring context.
*
* @param args command-line arguments passed to Spring Boot
*/
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(VodSystemApplication.class, args); SpringApplication.run(VodSystemApplication.class, args);
} }

View File

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

View File

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

View File

@@ -22,14 +22,6 @@ public class SecurityConfig {
this.jwtFilter = jwtFilter; this.jwtFilter = jwtFilter;
} }
/**
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
* installed, and per-endpoint authorization rules.
*
* @param http the HTTP security builder to configure
* @return the built {@link SecurityFilterChain}
* @throws Exception if the security configuration cannot be built
*/
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
@@ -38,9 +30,8 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
.requestMatchers("/api/v1/media/**").authenticated() .requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll() .requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()

View File

@@ -6,12 +6,14 @@ import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.*; 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 org.springframework.web.bind.annotation.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/api/v1/clips") @RequestMapping("/api/v1/clips")
@@ -24,11 +26,6 @@ public class ClipController {
this.clipService = clipService; this.clipService = clipService;
} }
/**
* Lists all clips belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's clips as {@link ClipResponse} DTOs
*/
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() { public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser(); List<Clip> clips = clipService.getClipsByUser();
@@ -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}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Clip clip = clipService.getClipById(id); Clip clip = clipService.getClipById(id);
@@ -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}") @PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id, public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) { @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}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
clipService.deleteClip(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) @GetMapping(value = "/{id}/media", produces = MediaType.ALL_VALUE)
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadClip(@PathVariable Long id) throws IOException {
Resource resource = clipService.downloadClip(id); Resource resource = clipService.downloadClip(id);
@@ -124,15 +95,8 @@ public class ClipController {
.body(resource); .body(resource);
} }
/**
* Streams the clip's thumbnail image with a public cache policy.
*
* @param id the ID of the clip whose thumbnail to download
* @return {@code 200 OK} with the thumbnail resource, or {@code 404 Not Found} if the file is missing
* @throws IOException if the resource's last-modified time cannot be read
*/
@GetMapping("/{id}/thumbnail") @GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) throws IOException { public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id); Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) { if (resource == null || !resource.exists()) {
@@ -140,8 +104,6 @@ public class ClipController {
} }
return ResponseEntity.ok() return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(2, TimeUnit.DAYS).cachePublic())
.lastModified(resource.lastModified())
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);

View File

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

View File

@@ -28,13 +28,6 @@ public class JobController {
this.jobRegistryService = jobRegistryService; this.jobRegistryService = jobRegistryService;
} }
/**
* Retrieves the status and progress of an asynchronous job.
*
* @param uuid the UUID of the job to look up
* @return {@code 200 OK} wrapping the job's state as a {@link JobResponse} DTO
* @throws com.ddf.vodsystem.exceptions.JobNotFound if no job with {@code uuid} exists
*/
@GetMapping("/{uuid}") @GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) { public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid); Job job = jobRegistryService.getJob(uuid);
@@ -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") @GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) { public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid); Job job = jobRegistryService.getJob(uuid);

View File

@@ -22,11 +22,6 @@ public class MarkerController {
this.markerService = markerService; this.markerService = markerService;
} }
/**
* Lists all markers belonging to the authenticated user.
*
* @return {@code 200 OK} wrapping the user's markers as {@link MarkerResponse} DTOs
*/
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() { public ResponseEntity<APIResponse<List<MarkerResponse>>> getMarkers() {
List<MarkerResponse> markers = markerService.getUserMarkers().stream() List<MarkerResponse> markers = markerService.getUserMarkers().stream()
@@ -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}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) { public ResponseEntity<APIResponse<MarkerResponse>> getMarkerById(@PathVariable Long id) {
Marker marker = markerService.getMarkerById(id); Marker marker = markerService.getMarkerById(id);
@@ -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("") @PostMapping("")
public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) { public ResponseEntity<APIResponse<MarkerResponse>> createMarker(@Valid @RequestBody MarkerCreateRequest request) {
Marker marker = markerService.create(request.message()); Marker marker = markerService.create(request.message());
@@ -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}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteMarker(@PathVariable Long id) {
markerService.deleteMarker(id); markerService.deleteMarker(id);

View File

@@ -26,14 +26,6 @@ public class MediaController {
this.mediaService = mediaService; this.mediaService = mediaService;
} }
/**
* Starts an asynchronous job to compress an uploaded video file.
*
* @param file the uploaded video to compress
* @param options trim window, resolution, fps, and target size for the output
* @return {@code 200 OK} wrapping the new job's UUID for polling
* @throws IOException if saving the upload to temporary storage fails
*/
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress( public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file, @RequestParam("file")MultipartFile file,
@@ -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") @PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save( public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException { @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") @PostMapping("/save/markers")
public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers( public ResponseEntity<APIResponse<UUIDResponse>> saveByMarkers(
@Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException { @Valid @RequestBody SaveSectionByMarkerRequest saveSectionByMarkerRequest) throws IOException {
@@ -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") @PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip( public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException { @RequestBody ClipSectionRequest clipSectionRequest) throws IOException {

View File

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

View File

@@ -27,12 +27,6 @@ public class UserController {
this.userService = userService; this.userService = userService;
} }
/**
* Returns the currently authenticated user's profile.
*
* @return {@code 200 OK} wrapping the authenticated {@link User}
* @throws NotAuthenticated if no valid session is present
*/
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() { public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -46,17 +40,6 @@ public class UserController {
); );
} }
/**
* Authenticates a user from a Google ID token and issues a session JWT.
* <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
*/
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token, public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request, HttpServletRequest request,
@@ -78,13 +61,6 @@ public class UserController {
); );
} }
/**
* Logs the user out by clearing the session cookie.
*
* @param response the response the expired cookie is written to
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response, public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) { HttpServletRequest request) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,12 +26,6 @@ public class JwtService {
this.algorithm = Algorithm.HMAC256(jwtSecretKey); 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) { public String generateToken(Long userId) {
return JWT.create() return JWT.create()
.withClaim(USER_ID_CLAIM, userId) .withClaim(USER_ID_CLAIM, userId)
@@ -40,12 +34,6 @@ public class JwtService {
.sign(algorithm); .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) { public Long validateTokenAndGetUserId(String token) {
try { try {
JWTVerifier verifier = JWT.require(algorithm) JWTVerifier verifier = JWT.require(algorithm)

View File

@@ -40,132 +40,61 @@ public class DirectoryService {
private static final String CLIPS_DIR_NAME = "clips"; private static final String CLIPS_DIR_NAME = "clips";
private static final String THUMBNAIL_DIR_NAME = "thumbnails"; private static final String THUMBNAIL_DIR_NAME = "thumbnails";
/**
* Returns the directory for temporary upload inputs.
*
* @return the configured temporary input directory path
*/
public Path getTempInputDir() { public Path getTempInputDir() {
return Path.of(tempInputsDir); return Path.of(tempInputsDir);
} }
/**
* Returns the directory for temporary processing outputs.
*
* @return the configured temporary output directory path
*/
public Path getTempOutputDir() { public Path getTempOutputDir() {
return Path.of(tempOutputsDir); return Path.of(tempOutputsDir);
} }
/**
* Returns the given user's root media directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's media directory path
* @throws IOException if the directory cannot be created
*/
public Path getUserDir(Long userId) throws IOException { public Path getUserDir(Long userId) throws IOException {
Path userDir = Path.of(usersDir + File.separator + userId); Path userDir = Path.of(usersDir + File.separator + userId);
Files.createDirectories(userDir); Files.createDirectories(userDir);
return userDir; return userDir;
} }
/**
* Returns the given user's VoDs directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's VoDs directory path
* @throws IOException if the directory cannot be created
*/
public Path getVodsDir(Long userId) throws IOException { public Path getVodsDir(Long userId) throws IOException {
Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME); Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
Files.createDirectories(vodDir); Files.createDirectories(vodDir);
return vodDir; return vodDir;
} }
/**
* Returns the given user's clips directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's clips directory path
* @throws IOException if the directory cannot be created
*/
public Path getClipsDir(Long userId) throws IOException { public Path getClipsDir(Long userId) throws IOException {
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME); Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
Files.createDirectories(clipsDir); Files.createDirectories(clipsDir);
return clipsDir; return clipsDir;
} }
/**
* Returns the given user's thumbnails directory, creating it if necessary.
*
* @param userId the ID of the user
* @return the user's thumbnails directory path
* @throws IOException if the directory cannot be created
*/
public Path getThumbnailsDir(Long userId) throws IOException { public Path getThumbnailsDir(Long userId) throws IOException {
Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME); Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
Files.createDirectories(thumbnailsDir); Files.createDirectories(thumbnailsDir);
return thumbnailsDir; return thumbnailsDir;
} }
/**
* Returns the directory holding a stream's recorded HLS segments, creating it if necessary.
*
* @param streamKey the stream key naming the segment directory
* @return the stream's segment directory path
* @throws IOException if the directory cannot be created
*/
public Path getStreamDir(String streamKey) throws IOException { public Path getStreamDir(String streamKey) throws IOException {
Path streamFolder = Path.of(streamsDir, streamKey); Path streamFolder = Path.of(streamsDir, streamKey);
Files.createDirectories(streamFolder); Files.createDirectories(streamFolder);
return streamFolder; return streamFolder;
} }
/**
* Converts an absolute path into one relative to the users media root, for storage in the database.
*
* @param path the absolute path to relativise
* @return the path relative to the users media directory
*/
public Path relativisePath(Path path) { public Path relativisePath(Path path) {
Path media = Path.of(usersDir).toAbsolutePath(); Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = path.toAbsolutePath(); Path newPath = path.toAbsolutePath();
return media.relativize(newPath); return media.relativize(newPath);
} }
/**
* Resolves a stored relative path back into an absolute path under the users media root.
*
* @param path the relative path, as stored in the database
* @return the absolute path on disk
*/
public Path resolvePath(String path) { public Path resolvePath(String path) {
Path media = Path.of(usersDir).toAbsolutePath(); Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = Path.of(path); Path newPath = Path.of(path);
return media.resolve(newPath); return media.resolve(newPath);
} }
/**
* Writes an uploaded file to the given path, creating parent directories and overwriting any existing file.
*
* @param path the destination path
* @param multipartFile the uploaded file to save
* @throws IOException if writing the file fails
*/
public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException { public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
Files.createDirectories(path.getParent()); Files.createDirectories(path.getParent());
Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
} }
/**
* Copies a file, creating parent directories and overwriting any existing target.
*
* @param source the file to copy from
* @param target the destination path
* @throws IOException if the copy fails
*/
public void copyFile(Path source, Path target) throws IOException { public void copyFile(Path source, Path target) throws IOException {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
@@ -217,12 +146,6 @@ public class DirectoryService {
} }
} }
/**
* Creates the temporary and users media directories on startup and verifies they are usable.
*
* @throws IOException if a directory cannot be created
* @throws IllegalStateException if any required directory is not a readable, writable directory
*/
@PostConstruct @PostConstruct
public void createDirectoriesIfNotExist() throws IOException { public void createDirectoriesIfNotExist() throws IOException {
Files.createDirectories(Path.of(tempInputsDir)); Files.createDirectories(Path.of(tempInputsDir));
@@ -232,13 +155,6 @@ public class DirectoryService {
checkDirPermissions(); checkDirPermissions();
} }
/**
* Scheduled cleanup that deletes stale files from the temporary input and output directories.
* <p>
* Runs every 30 minutes; files older than 3 hours are removed.
*
* @throws IOException if listing a temporary directory fails
*/
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
public void cleanTempDirectories() throws IOException { public void cleanTempDirectories() throws IOException {
cleanUpDirectory(tempInputsDir); cleanUpDirectory(tempInputsDir);

View File

@@ -32,14 +32,6 @@ public class MarkerService {
this.markerRepository = markerRepository; this.markerRepository = markerRepository;
} }
/**
* Returns the marker with the given ID, verifying ownership by the current user.
*
* @param id the ID of the marker to retrieve
* @return the matching {@link Marker}
* @throws MarkerNotFound if no marker with {@code id} exists
* @throws NotAuthenticated if the current user does not own the marker
*/
public Marker getMarkerById(Long id) { public Marker getMarkerById(Long id) {
Optional<Marker> marker = markerRepository.findById(id); Optional<Marker> marker = markerRepository.findById(id);
@@ -55,12 +47,6 @@ public class MarkerService {
return marker.get(); return marker.get();
} }
/**
* Returns all markers belonging to the currently authenticated user.
*
* @return list of the current user's markers
* @throws NotAuthenticated if no user session is present
*/
public List<Marker> getUserMarkers() { public List<Marker> getUserMarkers() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -71,13 +57,6 @@ public class MarkerService {
return markerRepository.findByUser(user.get()); return markerRepository.findByUser(user.get());
} }
/**
* Creates a marker timestamped now on the current user's active stream.
*
* @param message the marker's descriptive message
* @return the persisted {@link Marker}
* @throws NotStreaming if the user has no active stream to mark
*/
public Marker create(String message) { public Marker create(String message) {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
@@ -93,23 +72,11 @@ public class MarkerService {
return markerRepository.saveAndFlush(marker); return markerRepository.saveAndFlush(marker);
} }
/**
* Deletes a marker owned by the current user.
*
* @param id the ID of the marker to delete
* @throws MarkerNotFound if no marker with {@code id} exists
* @throws NotAuthenticated if the current user does not own the marker
*/
public void deleteMarker(Long id) { public void deleteMarker(Long id) {
Marker marker = getMarkerById(id); Marker marker = getMarkerById(id);
markerRepository.delete(marker); markerRepository.delete(marker);
} }
/**
* Scheduled cleanup that deletes markers older than the configured maximum stream length.
* <p>
* Runs periodically (every 6 minutes) to purge markers left behind by streams that have ended.
*/
@Scheduled(fixedDelay = 360_000) @Scheduled(fixedDelay = 360_000)
@Transactional @Transactional
public void deleteOldMarkers() { public void deleteOldMarkers() {

View File

@@ -30,14 +30,6 @@ public class StreamService {
this.userService = userService; this.userService = userService;
} }
/**
* Starts a new stream for the user identified by the given stream key.
*
* @param streamKey the stream key resolving to the broadcasting user
* @return the newly persisted {@link Stream}
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws AlreadyStreaming if the user already has an active stream
*/
public Stream startStream(String streamKey) { public Stream startStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -53,13 +45,6 @@ public class StreamService {
return streamRepository.saveAndFlush(stream); return streamRepository.saveAndFlush(stream);
} }
/**
* Ends the active stream for the user identified by the given stream key.
*
* @param streamKey the stream key resolving to the broadcasting user
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws IllegalStateException if the user has no active stream
*/
public void endStream(String streamKey) { public void endStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -71,13 +56,6 @@ public class StreamService {
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
/**
* Records a liveness heartbeat on the active stream, refreshing its last-seen time.
*
* @param streamKey the stream key resolving to the broadcasting user
* @throws KeyNotFound if no user owns {@code streamKey}
* @throws IllegalStateException if the user has no active stream
*/
public void heartbeatStream(String streamKey) { public void heartbeatStream(String streamKey) {
User user = resolveUser(streamKey); User user = resolveUser(streamKey);
@@ -89,12 +67,6 @@ public class StreamService {
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
/**
* Returns the current user's active (not-yet-ended) stream, if any.
*
* @return the active {@link Stream}, or empty if the user is not currently streaming
* @throws NotAuthenticated if no user session is present
*/
public Optional<Stream> getActiveStream() { public Optional<Stream> getActiveStream() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -105,14 +77,6 @@ public class StreamService {
return streamRepository.findByUserAndEndDateIsNull(user.get()); return streamRepository.findByUserAndEndDateIsNull(user.get());
} }
/**
* Returns the full stream history for the given user.
*
* @param userId the ID of the user whose history to retrieve
* @return the user's streams, past and present
* @throws IllegalArgumentException if no user with {@code userId} exists
* @throws NotAuthenticated if no user is authenticated, or the caller is not that user
*/
public List<Stream> getStreamHistory(Long userId) { public List<Stream> getStreamHistory(Long userId) {
User streamUser = userService.getUserById(userId) User streamUser = userService.getUserById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
@@ -130,12 +94,6 @@ public class StreamService {
return streamRepository.findByUser(streamUser); return streamRepository.findByUser(streamUser);
} }
/**
* Scheduled sweep that ends streams which have missed heartbeats past the timeout.
* <p>
* Runs every 30 seconds; any active stream whose last heartbeat is older than
* {@value #HEARTBEAT_TIMEOUT_SECONDS} seconds is marked ended.
*/
@Scheduled(fixedDelay = 30_000) @Scheduled(fixedDelay = 30_000)
@Transactional @Transactional
public void endStaleStreams() { public void endStaleStreams() {

View File

@@ -44,21 +44,10 @@ public class UserService {
this.jwtService = jwtService; this.jwtService = jwtService;
} }
/**
* Looks up a user by their database ID.
*
* @param userId the ID of the user to find
* @return the matching {@link User}, or empty if none exists
*/
public Optional<User> getUserById(Long userId) { public Optional<User> getUserById(Long userId) {
return userRepository.findById(userId); return userRepository.findById(userId);
} }
/**
* Returns the user backing the current Spring Security authentication, if authenticated.
*
* @return the authenticated {@link User}, or empty if no user is authenticated
*/
public Optional<User> getLoggedInUser() { public Optional<User> getLoggedInUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) { if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof User user) {
@@ -67,13 +56,6 @@ public class UserService {
return Optional.empty(); return Optional.empty();
} }
/**
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
*
* @param idToken the Google ID token to verify
* @return a signed JWT for the authenticated user
* @throws NotAuthenticated if the token is invalid or its subject is missing
*/
public String login(String idToken) { public String login(String idToken) {
GoogleIdToken googleIdToken = getGoogleIdToken(idToken); GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
String googleId = googleIdToken.getPayload().getSubject(); String googleId = googleIdToken.getPayload().getSubject();
@@ -88,12 +70,6 @@ public class UserService {
return jwtService.generateToken(user.getId()); return jwtService.generateToken(user.getId());
} }
/**
* Looks up a user by their stream key.
*
* @param streamKey the stream key to match
* @return the matching {@link User}, or empty if none exists
*/
public Optional<User> getUserByStreamKey(String streamKey) { public Optional<User> getUserByStreamKey(String streamKey) {
return userRepository.findByStreamKey(streamKey); return userRepository.findByStreamKey(streamKey);
} }

View File

@@ -16,15 +16,6 @@ import java.util.regex.Pattern;
public class CommandRunner { public class CommandRunner {
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
/**
* Runs an external command, capturing its merged stdout/stderr and streaming each line to a callback.
*
* @param command the command and its arguments
* @param onOutput invoked for each output line as it is read, or {@code null} to skip streaming
* @return the captured output and exit code
* @throws IOException if the process fails to start or exits with a non-zero code
* @throws InterruptedException if the current thread is interrupted while awaiting the process
*/
public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException { public CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
@@ -52,27 +43,10 @@ public class CommandRunner {
return commandOutput; return commandOutput;
} }
/**
* Runs an external command, capturing its output without streaming.
*
* @param command the command and its arguments
* @return the captured output and exit code
* @throws IOException if the process fails to start or exits with a non-zero code
* @throws InterruptedException if the current thread is interrupted while awaiting the process
*/
public CommandOutput run(List<String> command) throws IOException, InterruptedException { public CommandOutput run(List<String> command) throws IOException, InterruptedException {
return run(command, null); return run(command, null);
} }
/**
* Parses an ffmpeg progress line and updates the tracker with the fraction completed.
* <p>
* Lines without an {@code out_time_ms} field are ignored.
*
* @param line a single line of ffmpeg {@code -progress} output
* @param progress the tracker to update
* @param length the total media length in seconds, used as the progress denominator
*/
public void setProgress(String line, ProgressTracker progress, float length) { public void setProgress(String line, ProgressTracker progress, float length) {
Matcher matcher = timePattern.matcher(line); Matcher matcher = timePattern.matcher(line);
if (matcher.find()) { if (matcher.find()) {

View File

@@ -27,17 +27,6 @@ public class CompressionService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Compresses (and optionally trims/rescales) a video with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the compressed output
* @param clipOptions the trim window, target resolution/fps, and size driving the ffmpeg command
* @param progress the tracker updated as encoding progresses
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> compress(Path inputFile, public CompletableFuture<CommandOutput> compress(Path inputFile,
Path outputFile, Path outputFile,

View File

@@ -26,14 +26,6 @@ public class MetadataService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Probes a video file with ffprobe and returns its metadata (duration, dimensions, fps, size).
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param file the video file to probe
* @return a future completing with the parsed {@link ClipOptions}, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<ClipOptions> getVideoMetadata(Path file) { public CompletableFuture<ClipOptions> getVideoMetadata(Path file) {
logger.info("Getting metadata for file {}", file); logger.info("Getting metadata for file {}", file);

View File

@@ -21,17 +21,6 @@ public class RemuxService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Re-encodes a video to H.264/AAC in an MP4 container with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the remuxed MP4
* @param remuxProgress the tracker updated as encoding progresses
* @param length the media length in seconds, used to compute progress
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> remux(File inputFile, public CompletableFuture<CommandOutput> remux(File inputFile,
File outputFile, File outputFile,

View File

@@ -20,16 +20,6 @@ public class ThumbnailService {
this.commandRunner = commandRunner; this.commandRunner = commandRunner;
} }
/**
* Extracts a single frame from a video as a thumbnail image with ffmpeg on a background executor.
* <p>
* Runs asynchronously; failures complete the returned future exceptionally rather than throwing.
*
* @param inputFile the source video file
* @param outputFile the destination for the thumbnail image
* @param timeInVideo the timestamp in seconds at which to capture the frame
* @return a future completing with the ffmpeg output, or exceptionally on failure
*/
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) { public CompletableFuture<CommandOutput> createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) {
logger.info("Creating thumbnail at {} seconds", timeInVideo); logger.info("Creating thumbnail at {} seconds", timeInVideo);