-
-

+
+
+

-
- {formatTime(duration)}
+ {/* Hover scrim + play affordance */}
+
- {(onEdit || onDelete) && (
- e.preventDefault()}
- >
-
+
+ {formatTime(duration)}
+
- {menuOpen && (
-
- {!confirmDelete && (<>
- {onEdit && (
-
- )}
- {onDelete && (
-
- )}
- >)}
- {confirmDelete && (
-
-
Delete this {itemLabel}?
-
-
-
-
-
- )}
-
+ {hasMenu && (
+
e.preventDefault()}
+ >
+
- )}
-
+ >
+
+
-
-
- {title === "" ? "(No Title)" : title}
-
-
{timeAgo}
-
+ {menuOpen && (
+
+ {confirmDelete ? (
+
+
Delete this {itemLabel}?
+
+
+
+
+
+ ) : (
+ <>
+ {onEdit && (
+
}
+ onClick={(e) => stopAndRun(e, () => { setMenuOpen(false); onEdit(); })}
+ >
+ Edit details
+
+ )}
+ {onEdit && onDelete &&
}
+ {onDelete && (
+
}
+ tone="danger"
+ onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
+ >
+ Delete
+
+ )}
+ >
+ )}
+
+ )}
+
+ )}
+
+
+
+
+ {title === "" ? "Untitled" : title}
+
+
{timeAgo}
);
-}
+};
export default VideoCard;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 6c52b81..127b47a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,45 +1,358 @@
@import "tailwindcss";
-@plugin "flowbite-react/plugin/tailwindcss";
-@source "../.flowbite-react/class-list.json";
-@theme {
- --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+/* Dark mode is driven by a `.dark` class on (see ThemeProvider). */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Palette
+ Warm, paper-toned surfaces with a terracotta accent. Every semantic token
+ below points at one of these raw values so that light/dark is a single
+ swap of the `--c-*` layer.
+ ────────────────────────────────────────────────────────────────────────── */
+:root {
+ /* Surfaces */
+ --c-canvas: #faf9f5;
+ --c-panel: #f0eee6;
+ --c-surface: #ffffff;
+ --c-surface-hover: #f5f4ee;
+ --c-surface-sunken: #f2f1ea;
+
+ /* Ink */
+ --c-fg: #1f1e1d;
+ --c-fg-muted: #6c6a63;
+ --c-fg-subtle: #97948b;
+ --c-fg-inverted: #faf9f5;
+
+ /* Lines */
+ --c-line: #e6e3d9;
+ --c-line-strong: #d5d1c3;
+
+ /* Accent — Claude terracotta */
+ --c-accent: #d97757;
+ --c-accent-hover: #c25f3f;
+ --c-accent-fg: #ffffff;
+ --c-accent-ink: #a44a2c;
+
+ /* Secondary brand tones */
+ --c-kraft: #d4a27f;
+ --c-manilla: #ebdbbc;
+
+ /* Status */
+ --c-danger: #b4432c;
+ --c-danger-hover: #9a3623;
+ --c-success: #4f7a52;
+ --c-live: #d1453b;
+
+ /* Elevation */
+ --c-shadow: 30 27 20;
+ --shadow-ambient: 0 1px 2px rgb(var(--c-shadow) / 0.05), 0 1px 1px rgb(var(--c-shadow) / 0.04);
+ --shadow-raised: 0 2px 4px rgb(var(--c-shadow) / 0.05), 0 8px 24px rgb(var(--c-shadow) / 0.07);
+ --shadow-overlay: 0 4px 8px rgb(var(--c-shadow) / 0.06), 0 16px 40px rgb(var(--c-shadow) / 0.14);
+
+ color-scheme: light;
+}
+
+.dark {
+ --c-canvas: #262624;
+ --c-panel: #1f1e1d;
+ --c-surface: #30302e;
+ --c-surface-hover: #3a3a37;
+ --c-surface-sunken: #242422;
+
+ --c-fg: #f5f4ee;
+ --c-fg-muted: #a5a29a;
+ --c-fg-subtle: #7d7a72;
+ --c-fg-inverted: #1f1e1d;
+
+ --c-line: #403f3b;
+ --c-line-strong: #55534d;
+
+ --c-accent: #d97757;
+ --c-accent-hover: #e58e70;
+ --c-accent-fg: #1f1e1d;
+ --c-accent-ink: #e9a184;
+
+ --c-kraft: #c99268;
+ --c-manilla: #b7a887;
+
+ --c-danger: #e0705a;
+ --c-danger-hover: #ec8672;
+ --c-success: #7aa87d;
+ --c-live: #e5544a;
+
+ --c-shadow: 0 0 0;
+ --shadow-ambient: 0 1px 2px rgb(var(--c-shadow) / 0.24);
+ --shadow-raised: 0 2px 4px rgb(var(--c-shadow) / 0.2), 0 8px 24px rgb(var(--c-shadow) / 0.28);
+ --shadow-overlay: 0 4px 8px rgb(var(--c-shadow) / 0.28), 0 16px 40px rgb(var(--c-shadow) / 0.45);
+
+ color-scheme: dark;
+}
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Design tokens exposed to Tailwind
+ ────────────────────────────────────────────────────────────────────────── */
+@theme inline {
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+ --font-serif: "Source Serif 4", ui-serif, Georgia, Cambria, "Times New Roman", serif;
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+
+ --color-canvas: var(--c-canvas);
+ --color-panel: var(--c-panel);
+ --color-surface: var(--c-surface);
+ --color-surface-hover: var(--c-surface-hover);
+ --color-sunken: var(--c-surface-sunken);
+
+ --color-fg: var(--c-fg);
+ --color-muted: var(--c-fg-muted);
+ --color-subtle: var(--c-fg-subtle);
+ --color-inverted: var(--c-fg-inverted);
+
+ --color-line: var(--c-line);
+ --color-line-strong: var(--c-line-strong);
+
+ --color-accent: var(--c-accent);
+ --color-accent-hover: var(--c-accent-hover);
+ --color-accent-fg: var(--c-accent-fg);
+ --color-accent-ink: var(--c-accent-ink);
+
+ --color-kraft: var(--c-kraft);
+ --color-manilla: var(--c-manilla);
+
+ --color-danger: var(--c-danger);
+ --color-danger-hover: var(--c-danger-hover);
+ --color-success: var(--c-success);
+ --color-live: var(--c-live);
+
+ --shadow-ambient: var(--shadow-ambient);
+ --shadow-raised: var(--shadow-raised);
+ --shadow-overlay: var(--shadow-overlay);
- /* Breakpoints */
--breakpoint-3xl: 1920px;
- --color-primary: oklch(0.55 0.21 254);
- --color-primary-pressed: oklch(0.45 0.21 254);
- --color-secondary: oklch(0.94 0.01 250);
- --color-accent: oklch(0.68 0.20 288);
- --color-text: oklch(0.17 0.01 270);
- --color-background: oklch(0.97 0.005 250);
- --color-surface: oklch(1 0 0);
- --color-border: oklch(0.91 0.008 250);
-
- /* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
+
+ --animate-rise: rise 0.35s cubic-bezier(0.3, 0, 0, 1) both;
+ --animate-fade: fade 0.25s ease-out both;
+ --animate-pop: pop 0.16s cubic-bezier(0.2, 0, 0, 1) both;
+ --animate-shimmer: shimmer 1.6s ease-in-out infinite;
+}
+
+@keyframes rise {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
+
+@keyframes fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes pop {
+ from { opacity: 0; transform: scale(0.96) translateY(-4px); }
+ to { opacity: 1; transform: none; }
+}
+
+@keyframes shimmer {
+ 0%, 100% { opacity: 0.45; }
+ 50% { opacity: 0.8; }
+}
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Base
+ ────────────────────────────────────────────────────────────────────────── */
+@layer base {
+ * {
+ border-color: var(--c-line);
+ }
+
+ html {
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ font-synthesis-weight: none;
+ }
+
+ body {
+ background-color: var(--c-canvas);
+ color: var(--c-fg);
+ line-height: 1.5;
+ font-feature-settings: "cv11", "ss01";
+ font-variation-settings: "opsz" 32;
+ }
+
+ h1, h2, h3 {
+ letter-spacing: -0.018em;
+ }
+
+ ::selection {
+ background-color: color-mix(in oklab, var(--c-accent) 26%, transparent);
+ color: var(--c-fg);
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--c-accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+ }
+
+ /* Scrollbars — thin and unobtrusive, warm-toned */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: var(--c-line-strong) transparent;
+ }
+
+ ::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: var(--c-line-strong);
+ border-radius: 999px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: var(--c-fg-subtle);
+ }
+}
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Form controls
+ ────────────────────────────────────────────────────────────────────────── */
+@layer components {
+ .field {
+ width: 100%;
+ border-radius: 0.625rem;
+ border: 1px solid var(--c-line-strong);
+ background-color: var(--c-surface);
+ color: var(--c-fg);
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.4rem;
+ transition: border-color 150ms, box-shadow 150ms, background-color 150ms;
+ }
+
+ .field::placeholder {
+ color: var(--c-fg-subtle);
+ }
+
+ .field:hover:not(:disabled):not(:focus) {
+ border-color: color-mix(in oklab, var(--c-fg-subtle) 60%, var(--c-line-strong));
+ }
+
+ .field:focus {
+ outline: none;
+ border-color: var(--c-accent);
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--c-accent) 18%, transparent);
+ }
+
+ .field:disabled {
+ background-color: var(--c-surface-sunken);
+ color: var(--c-fg-subtle);
+ cursor: not-allowed;
+ }
+
+ select.field {
+ appearance: none;
+ background-image: url("data:image/svg+xml;utf8,
");
+ background-repeat: no-repeat;
+ background-position: right 0.6rem center;
+ padding-right: 2rem;
+ }
+
+ .dark select.field {
+ background-image: url("data:image/svg+xml;utf8,
");
+ }
+
+ /* Native range inputs (volume, scrub) */
+ .slider {
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+ height: 1rem;
+ }
+
+ .slider::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--c-fg) 14%, transparent);
+ }
+
+ .slider::-moz-range-track {
+ height: 4px;
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--c-fg) 14%, transparent);
+ }
+
+ .slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ margin-top: -4px;
+ height: 12px;
+ width: 12px;
+ border-radius: 999px;
+ background: var(--c-accent);
+ border: none;
+ transition: transform 120ms;
+ }
+
+ .slider::-moz-range-thumb {
+ height: 12px;
+ width: 12px;
+ border: none;
+ border-radius: 999px;
+ background: var(--c-accent);
+ }
+
+ .slider:hover::-webkit-slider-thumb { transform: scale(1.25); }
+ .slider:active::-webkit-slider-thumb { transform: scale(1.35); }
+
+ /* Light-on-dark variant used over video */
+ .slider-onvideo::-webkit-slider-runnable-track { background: rgb(255 255 255 / 0.28); }
+ .slider-onvideo::-moz-range-track { background: rgb(255 255 255 / 0.28); }
+ .slider-onvideo::-webkit-slider-thumb { background: #fff; }
+ .slider-onvideo::-moz-range-thumb { background: #fff; }
+}
+
+/* react-range-slider-input — trim range on the clip editor */
+#range-slider {
+ height: 8px;
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--c-fg) 10%, transparent);
+ border: none;
}
#range-slider .range-slider__range {
- background: var(--color-primary);
+ background: var(--c-accent);
}
#range-slider .range-slider__thumb {
- background: var(--color-primary);
+ width: 16px;
+ height: 16px;
+ background: var(--c-surface);
+ border: 3px solid var(--c-accent);
+ box-shadow: var(--shadow-ambient);
+ transition: transform 120ms cubic-bezier(0.2, 0, 0, 1);
}
-:root {
- font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
- line-height: 1.5;
- font-weight: 400;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+#range-slider .range-slider__thumb:hover,
+#range-slider .range-slider__thumb[data-active="true"] {
+ transform: translate(-50%, -50%) scale(1.15);
}
-body {
- background-color: var(--color-background);
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
}
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index 6ba6821..91fac76 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -1,30 +1,36 @@
-// layout/MainLayout.jsx
-import Sidebar from '../components/Sidebar'
-import Topbar from '../components/Topbar'
-import { Outlet } from 'react-router-dom';
-import {useEffect, useState} from "react";
-import type {User} from "../utils/types";
-import { getUser } from "../utils/api/users";
-import { getStreamStatus } from "../utils/api/stream";
+import { useCallback, useEffect, useState } from "react";
+import { Outlet, useLocation, useNavigate } from "react-router-dom";
+import clsx from "clsx";
+import type { CredentialResponse } from "@react-oauth/google";
+
+import Sidebar from "../components/Sidebar.tsx";
+import Topbar from "../components/Topbar.tsx";
+import type { User } from "../utils/types.ts";
+import { getUser, login, logout } from "../utils/api/users.ts";
+import { getStreamStatus } from "../utils/api/stream.ts";
+
+const isDesktop = () => typeof window === "undefined" || window.innerWidth >= 768;
const MainLayout = () => {
- const [sidebarToggled, setSidebarToggled] = useState(false);
- const [user, setUser] = useState
(null);
+ const [sidebarOpen, setSidebarOpen] = useState(isDesktop);
+ const [user, setUser] = useState(null);
const [isStreaming, setIsStreaming] = useState(false);
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
- const fetchUser = async () => {
+ const fetchUser = useCallback(async () => {
try {
- const userData = await getUser();
- setUser(userData);
+ setUser(await getUser());
} catch (error) {
- console.error('Failed to fetch user:', error);
+ console.error("Failed to fetch user:", error);
}
- };
+ }, []);
useEffect(() => {
fetchUser();
- }, []);
+ }, [fetchUser]);
+ // Keep the live indicator fresh for as long as someone is signed in.
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
@@ -33,22 +39,71 @@ const MainLayout = () => {
return () => clearInterval(interval);
}, [user]);
+ const handleLogin = (response: CredentialResponse) => {
+ if (!response.credential) {
+ console.error("No credential received from Google login.");
+ return;
+ }
+ login(response.credential)
+ .then(() => {
+ fetchUser();
+ navigate(0);
+ })
+ .catch((error) => console.error("Login failed:", error));
+ };
+
+ const handleLogout = () => {
+ logout()
+ .then(() => {
+ setUser(null);
+ navigate("/");
+ })
+ .catch((error) => console.error("Logout failed:", error));
+ };
+
+ // The sidebar overlays content on narrow screens, so dismiss it on navigation.
+ const closeOnMobile = () => {
+ if (!isDesktop()) setSidebarOpen(false);
+ };
+
return (
-
+
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ className="fixed inset-0 z-30 bg-fg/25 backdrop-blur-[2px] md:hidden"
+ aria-hidden
+ />
+ )}
+
-
-
-
+ isStreaming={isStreaming}
+ onLogout={handleLogout}
+ onNavigate={closeOnMobile}
+ className={clsx(
+ "fixed inset-y-0 left-0 z-40 w-[264px] shrink-0 transition-all duration-300 ease-fluid md:static",
+ sidebarOpen
+ ? "translate-x-0 md:w-[264px]"
+ : "-translate-x-full md:w-0 md:translate-x-0 md:border-r-0",
+ )}
+ />
+
+
+ setSidebarOpen((v) => !v)}
+ onLogin={handleLogin}
+ />
+
+
+
+
);
};
-export default MainLayout;
\ No newline at end of file
+
+export default MainLayout;
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
new file mode 100644
index 0000000..adce87f
--- /dev/null
+++ b/frontend/src/lib/theme.ts
@@ -0,0 +1,25 @@
+import { createContext, useContext } from "react";
+
+export type Theme = "light" | "dark";
+
+export const THEME_STORAGE_KEY = "vod-theme";
+
+type ThemeContextValue = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+ toggleTheme: () => void;
+};
+
+export const ThemeContext = createContext(null);
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) throw new Error("useTheme must be used inside a ThemeProvider");
+ return context;
+};
+
+/** Reads the theme the inline script in index.html already applied to . */
+export const readInitialTheme = (): Theme => {
+ if (typeof document === "undefined") return "light";
+ return document.documentElement.classList.contains("dark") ? "dark" : "light";
+};
diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx
index cc0f4e4..9b29212 100644
--- a/frontend/src/pages/ClipEdit.tsx
+++ b/frontend/src/pages/ClipEdit.tsx
@@ -1,42 +1,46 @@
-import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react";
-import Playbar from "./../components/video/Playbar";
-import PlaybackSlider from "./../components/video/PlaybackSlider";
-import ClipRangeSlider from "./../components/video/ClipRangeSlider";
-import ConfigBox from "../components/video/ConfigBox.tsx";
-import ExportWidget from "../components/video/ExportWidget.tsx";
-import { compress } from "../utils/api/media"
-import { getJob } from "../utils/api/jobs"
+import { Link, useLocation } from "react-router-dom";
+import { FileVideo } from "lucide-react";
+
+import Card from "../components/ui/Card.tsx";
+import Button from "../components/ui/Button.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+import EmptyState from "../components/ui/EmptyState.tsx";
+import PreviewControls from "../components/video/PreviewControls.tsx";
+import TrimRange from "../components/video/TrimRange.tsx";
+import ExportPanel from "../components/video/ExportPanel.tsx";
+import { compress } from "../utils/api/media.ts";
+import { getJob } from "../utils/api/jobs.ts";
import type { VideoMetadata } from "../utils/types.ts";
-import Box from "../components/Box.tsx";
-import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => {
const location = useLocation();
- const localFile: File = location.state?.file;
+ const localFile: File | undefined = location.state?.file;
- const videoRef = useRef(null);
- const [videoUrl, setVideoUrl] = useState('');
- const [uploadedId, setUploadedId] = useState(null);
+ // Held in state (not a ref) so the controls re-render once the element exists.
+ const [videoEl, setVideoEl] = useState(null);
+ const [videoUrl, setVideoUrl] = useState("");
+ const [sourceDuration, setSourceDuration] = useState(0);
+ const [jobId, setJobId] = useState(null);
- const [metadata, setMetadata] = useState(null);
- const [playbackValue, setPlaybackValue] = useState(0);
- const [outputMetadata, setOutputMetadata] = useState({
+ const [output, setOutput] = useState({
title: "",
description: "",
startPoint: 0,
- duration: 5,
+ duration: 0,
width: 1280,
height: 720,
fps: 30,
- fileSize: 10000
+ fileSize: 10000,
});
+
const [progress, setProgress] = useState(0);
const [downloadable, setDownloadable] = useState(false);
- const [uploading, setUploading] = useState(false);
+ const [exporting, setExporting] = useState(false);
const [error, setError] = useState(null);
+ const pollRef = useRef | null>(null);
useEffect(() => {
if (!localFile) return;
@@ -45,139 +49,143 @@ const ClipEdit = () => {
return () => URL.revokeObjectURL(url);
}, [localFile]);
- const handleVideoMetadataLoaded = () => {
- const video = videoRef.current;
- if (!video) return;
- setMetadata({
- title: '',
- description: '',
+ useEffect(() => () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ }, []);
+
+ const handleMetadataLoaded = () => {
+ if (!videoEl) return;
+ setSourceDuration(videoEl.duration);
+ // Default the trim range to the whole clip and match the source frame size.
+ setOutput((prev) => ({
+ ...prev,
startPoint: 0,
- duration: video.duration,
- width: video.videoWidth,
- height: video.videoHeight,
- fps: 30,
- fileSize: localFile?.size ?? 0,
- });
+ duration: videoEl.duration,
+ width: videoEl.videoWidth || prev.width,
+ height: videoEl.videoHeight || prev.height,
+ }));
};
- const sendData = async () => {
- if (!localFile || uploading) return;
+ const pollJob = (uuid: string) => {
+ pollRef.current = setInterval(() => {
+ getJob(uuid)
+ .then((job) => {
+ setProgress(job.progress);
+ if (job.state === "FAILED") {
+ setError(`Compression failed: ${job.errorOutput ?? "Unknown error"}`);
+ stopPolling();
+ } else if (job.isComplete) {
+ setDownloadable(true);
+ stopPolling();
+ }
+ })
+ .catch((err: Error) => {
+ setError(`Failed to fetch progress: ${err.message}`);
+ stopPolling();
+ });
+ }, 500);
+ };
- setUploading(true);
+ const stopPolling = () => {
+ if (pollRef.current) clearInterval(pollRef.current);
+ pollRef.current = null;
+ setExporting(false);
+ };
+
+ const handleExport = async () => {
+ if (!localFile || exporting) return;
+
+ setExporting(true);
setError(null);
+ setProgress(0);
- let jobId: string;
try {
- jobId = await compress(localFile, outputMetadata);
- setUploadedId(jobId);
+ const uuid = await compress(localFile, output);
+ setJobId(uuid);
+ pollJob(uuid);
} catch (err: unknown) {
- setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
- setUploading(false);
- return;
+ setError(`Failed to start compression: ${err instanceof Error ? err.message : "Unknown error"}`);
+ setExporting(false);
}
-
- const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
- };
-
- const pollProgress = async (jobId: string, intervalId: number) => {
- getJob(jobId)
- .then((job) => {
- setProgress(job.progress);
-
- if (job.state === 'FAILED') {
- setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
- clearInterval(intervalId);
- setUploading(false);
- } else if (job.isComplete) {
- clearInterval(intervalId);
- setDownloadable(true);
- setUploading(false);
- }
- })
- .catch((err: Error) => {
- setError(`Failed to fetch progress: ${err.message}`);
- clearInterval(intervalId);
- setUploading(false);
- });
};
const handleDownload = async () => {
- if (!uploadedId) return;
-
- const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
+ if (!jobId) return;
+ const response = await fetch(`${API_URL}/api/v1/jobs/${jobId}/download`, { credentials: "include" });
if (!response.ok) {
- console.error('Download failed');
+ setError("Download failed.");
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
-
- a.href = url;
- a.download = `${uploadedId}.mp4`;
- document.body.appendChild(a);
- a.click();
- a.remove();
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `${output.title || jobId}.mp4`;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
window.URL.revokeObjectURL(url);
};
+ if (!localFile) {
+ return (
+
+ }
+ title="No video selected"
+ description="Pick a file first — the editor works on a video you choose from your device."
+ action={}
+ />
+
+ );
+ }
+
return (
-
-
);
};
diff --git a/frontend/src/pages/ClipUpload.tsx b/frontend/src/pages/ClipUpload.tsx
index cf4cbb4..f48fe30 100644
--- a/frontend/src/pages/ClipUpload.tsx
+++ b/frontend/src/pages/ClipUpload.tsx
@@ -1,42 +1,138 @@
-import {useState} from "react";
-import {useNavigate} from "react-router-dom";
-import BlueButton from "../components/buttons/BlueButton.tsx";
-import Box from "../components/Box.tsx";
+import { useRef, useState, type DragEvent } from "react";
+import { useNavigate } from "react-router-dom";
+import { ArrowRight, FileVideo, UploadCloud, X } from "lucide-react";
+import clsx from "clsx";
+
+import Card from "../components/ui/Card.tsx";
+import Button from "../components/ui/Button.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+
+const ACCEPTED = ["video/mp4", "video/quicktime", "video/webm", "video/ogg"];
+
+const formatBytes = (bytes: number) => {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ["KB", "MB", "GB"];
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`;
+};
const ClipUpload = () => {
const [file, setFile] = useState(null);
+ const [dragging, setDragging] = useState(false);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
const navigate = useNavigate();
- const [error, setError] = useState(null);
- const press = () => {
- if (!file) {
- setError("Please choose a file");
+ const accept = (selected: File | null | undefined) => {
+ if (!selected) return;
+ // Browsers occasionally report an empty type for known containers, so
+ // fall back to trusting the extension rather than blocking the upload.
+ const looksLikeVideo = selected.type
+ ? selected.type.startsWith("video/") || ACCEPTED.includes(selected.type)
+ : /\.(mp4|mov|webm|ogv|ogg)$/i.test(selected.name);
+
+ if (!looksLikeVideo) {
+ setError("That doesn't look like a video. Try MP4, MOV, WebM, or OGG.");
return;
}
- navigate('/create/new', { state: { file } });
+ setError(null);
+ setFile(selected);
+ };
+
+ const handleDrop = (e: DragEvent) => {
+ e.preventDefault();
+ setDragging(false);
+ accept(e.dataTransfer.files?.[0]);
+ };
+
+ const handleContinue = () => {
+ if (!file) {
+ setError("Choose a file to continue.");
+ return;
+ }
+ navigate("/create/new", { state: { file } });
};
return (
-
-
- Upload Clip
+
+
-
{
- const selected = e.target.files?.[0] ?? null;
- setFile(selected);
- setError(null);
- }}
- className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
- />
+
+ { e.preventDefault(); setDragging(true); }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={handleDrop}
+ onClick={() => inputRef.current?.click()}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && inputRef.current?.click()}
+ className={clsx(
+ "flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-14 text-center",
+ "transition-all duration-200 ease-fluid",
+ dragging
+ ? "border-accent bg-accent/8 scale-[1.01]"
+ : "border-line-strong bg-sunken hover:border-accent/60 hover:bg-accent/5",
+ )}
+ >
+
+
+
+
+ {dragging ? "Drop it here" : "Drag a video here, or click to browse"}
+
+
MP4, MOV, WebM, or OGG
-
Upload
+
accept(e.target.files?.[0])}
+ />
+
- {error && {error}
}
-
+ {file && (
+
+
+
+
+
+
{file.name}
+
{formatBytes(file.size)}
+
+
+
+ )}
+
+ {error && {error}
}
+
+
+ }>
+ Continue to editor
+
+
+
- )
+ );
};
export default ClipUpload;
diff --git a/frontend/src/pages/EditClip.tsx b/frontend/src/pages/EditClip.tsx
deleted file mode 100644
index dc5f8d2..0000000
--- a/frontend/src/pages/EditClip.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useEffect, useState } from "react";
-import { useNavigate, useParams } from "react-router-dom";
-import { ArrowLeft, Check } from "lucide-react";
-import { getClipById, patchClip } from "../utils/api/clips.ts";
-import Box from "../components/Box.tsx";
-import BlueButton from "../components/buttons/BlueButton.tsx";
-
-const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
-
-const EditClip = () => {
- const { id } = useParams();
- const navigate = useNavigate();
-
- const [title, setTitle] = useState("");
- const [description, setDescription] = useState("");
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [saved, setSaved] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!id) return;
- getClipById(id)
- .then((clip) => {
- if (clip) {
- setTitle(clip.title ?? "");
- setDescription(clip.description ?? "");
- }
- })
- .catch(() => setError("Failed to load clip."))
- .finally(() => setLoading(false));
- }, [id]);
-
- const handleSave = async () => {
- if (!id) return;
- setSaving(true);
- setError(null);
- try {
- await patchClip(Number(id), { title, description });
- setSaved(true);
- setTimeout(() => setSaved(false), 2000);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to save.");
- } finally {
- setSaving(false);
- }
- };
-
- return (
-
-
-
-
Edit Clip
-
- {loading ? (
-
Loading...
- ) : (
-
-
-
- setTitle(e.target.value)}
- placeholder="Enter title"
- className={inputClass}
- />
-
-
-
-
-
-
- {error && {error}
}
-
-
-
- {saving ? "Saving…" : saved
- ? Saved
- : "Save"}
-
-
-
-
- )}
-
- );
-};
-
-export default EditClip;
diff --git a/frontend/src/pages/EditMedia.tsx b/frontend/src/pages/EditMedia.tsx
new file mode 100644
index 0000000..ed251c5
--- /dev/null
+++ b/frontend/src/pages/EditMedia.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, Check } from "lucide-react";
+
+import Card from "../components/ui/Card.tsx";
+import Button from "../components/ui/Button.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+import Skeleton from "../components/ui/Skeleton.tsx";
+import { Field, Input, Textarea } from "../components/ui/Field.tsx";
+
+type Editable = { title?: string | null; description?: string | null };
+
+type Props = {
+ /** Title-case singular, e.g. "Clip" or "VoD". */
+ noun: string;
+ fetchDetails: (id: string) => Promise;
+ save: (id: number, data: { title: string; description: string }) => Promise;
+};
+
+const EditMedia = ({ noun, fetchDetails, save }: Props) => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!id) return;
+ fetchDetails(id)
+ .then((item) => {
+ if (!item) return;
+ setTitle(item.title ?? "");
+ setDescription(item.description ?? "");
+ })
+ .catch(() => setError(`Failed to load ${noun.toLowerCase()}.`))
+ .finally(() => setLoading(false));
+ }, [id, noun, fetchDetails]);
+
+ const handleSave = async () => {
+ if (!id) return;
+ setSaving(true);
+ setError(null);
+ try {
+ await save(Number(id), { title, description });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
}
+ onClick={() => navigate(-1)}
+ className="-ml-3 mb-5"
+ >
+ Back
+
+
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : (
+
+
+ {(fieldId) => (
+ setTitle(e.target.value)}
+ placeholder={`Name this ${noun.toLowerCase()}`}
+ />
+ )}
+
+
+
+ {(fieldId) => (
+
+
+ {error &&
{error}
}
+
+
+ : undefined}
+ className="min-w-28"
+ >
+ {saving ? "Saving" : saved ? "Saved" : "Save changes"}
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default EditMedia;
diff --git a/frontend/src/pages/EditVod.tsx b/frontend/src/pages/EditVod.tsx
deleted file mode 100644
index 89c1cd9..0000000
--- a/frontend/src/pages/EditVod.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useEffect, useState } from "react";
-import { useNavigate, useParams } from "react-router-dom";
-import { ArrowLeft, Check } from "lucide-react";
-import { getVodById, patchVod } from "../utils/api/vods.ts";
-import Box from "../components/Box.tsx";
-import BlueButton from "../components/buttons/BlueButton.tsx";
-
-const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
-
-const EditVod = () => {
- const { id } = useParams();
- const navigate = useNavigate();
-
- const [title, setTitle] = useState("");
- const [description, setDescription] = useState("");
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [saved, setSaved] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!id) return;
- getVodById(id)
- .then((vod) => {
- if (vod) {
- setTitle(vod.title ?? "");
- setDescription(vod.description ?? "");
- }
- })
- .catch(() => setError("Failed to load vod."))
- .finally(() => setLoading(false));
- }, [id]);
-
- const handleSave = async () => {
- if (!id) return;
- setSaving(true);
- setError(null);
- try {
- await patchVod(Number(id), { title, description });
- setSaved(true);
- setTimeout(() => setSaved(false), 2000);
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to save.");
- } finally {
- setSaving(false);
- }
- };
-
- return (
-
-
-
-
Edit VoD
-
- {loading ? (
-
Loading...
- ) : (
-
-
-
- setTitle(e.target.value)}
- placeholder="Enter title"
- className={inputClass}
- />
-
-
-
-
-
-
- {error && {error}
}
-
-
-
- {saving ? "Saving…" : saved
- ? Saved
- : "Save"}
-
-
-
-
- )}
-
- );
-};
-
-export default EditVod;
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
index c2f704d..711329b 100644
--- a/frontend/src/pages/Home.tsx
+++ b/frontend/src/pages/Home.tsx
@@ -1,4 +1,6 @@
-import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
+import { ArrowRight, LayoutGrid, Play, Radio, Scissors, SlidersHorizontal, Upload } from "lucide-react";
+import { Link } from "react-router-dom";
+import Button from "../components/ui/Button.tsx";
const features = [
{
@@ -33,48 +35,64 @@ const features = [
},
];
-const Home = () => {
- return (
-
- {/* Hero */}
-
-

-
- A home for your clips.
-
-
- Upload, trim, and export video clips. Stream live if you need to.
-
-
+const Home = () => (
+
+ {/* Hero */}
+
+
- {/* Feature grid */}
-
- {features.map(({ icon: Icon, title, description }) => (
-
-
-
-
-
-
{title}
-
{description}
-
-
- ))}
-
+
+ A home for your clips.
+
- {/* Format note */}
-
- Supports MP4, MOV, WebM, and OGG.
+
+ Upload, trim, and export video clips. Stream live when you need to,
+ and keep every recording in one place.
-
- );
-};
+
+
+
+ }>
+ Upload a clip
+
+
+
+ }>
+ Browse library
+
+
+
+
+
+ {/* Feature grid */}
+
+ {features.map(({ icon: Icon, title, description }) => (
+
+
+
+
+ {title}
+ {description}
+
+ ))}
+
+
+ {/* Footer strip */}
+
+ Ready when you are.
+
+ Supports MP4, MOV, WebM, and OGG. Sign in with Google to save clips to your library.
+
+
+ }>
+ Start a clip
+
+
+
+
+);
export default Home;
diff --git a/frontend/src/pages/MediaLibrary.tsx b/frontend/src/pages/MediaLibrary.tsx
new file mode 100644
index 0000000..e49a597
--- /dev/null
+++ b/frontend/src/pages/MediaLibrary.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { Search, TriangleAlert } from "lucide-react";
+import type { ReactNode } from "react";
+
+import VideoCard from "../components/video/VideoCard.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+import EmptyState from "../components/ui/EmptyState.tsx";
+import Skeleton from "../components/ui/Skeleton.tsx";
+import { Input } from "../components/ui/Field.tsx";
+
+/** The shared shape of clips and VoDs as far as the library grid is concerned. */
+type MediaSummary = {
+ id: number;
+ title: string;
+ duration: number;
+ createdAt: string;
+};
+
+type Props = {
+ title: string;
+ description: string;
+ /** Singular, lowercase — used in confirmations and empty copy, e.g. "clip". */
+ noun: string;
+ fetchAll: () => Promise;
+ remove: (id: number) => Promise;
+ editPath: (id: number) => string;
+ playerPath: string;
+ mediaApiPath: string;
+ emptyIcon: ReactNode;
+ emptyTitle: string;
+ emptyDescription: string;
+};
+
+const newestFirst = (a: MediaSummary, b: MediaSummary) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+
+const MediaLibrary = ({
+ title,
+ description,
+ noun,
+ fetchAll,
+ remove,
+ editPath,
+ playerPath,
+ mediaApiPath,
+ emptyIcon,
+ emptyTitle,
+ emptyDescription,
+}: Props) => {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [query, setQuery] = useState("");
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ fetchAll()
+ .then((data) => {
+ if (!cancelled) setItems([...data].sort(newestFirst));
+ })
+ .catch((err) => {
+ if (!cancelled) setError(err instanceof Error ? err.message : `Failed to load ${noun}s.`);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [fetchAll, noun]);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await remove(id);
+ setItems((prev) => prev.filter((item) => item.id !== id));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : `Failed to delete ${noun}.`);
+ }
+ };
+
+ const visible = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return items;
+ return items.filter((item) => item.title.toLowerCase().includes(q));
+ }, [items, query]);
+
+ return (
+
+
0 ? (
+
+
+ setQuery(e.target.value)}
+ placeholder={`Search ${noun}s`}
+ className="pl-9"
+ />
+
+ ) : undefined
+ }
+ />
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+ {loading ? (
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+
+
+
+
+ ))}
+
+ ) : items.length === 0 ? (
+
+ ) : visible.length === 0 ? (
+
}
+ title="No matches"
+ description={`Nothing here matches “${query}”.`}
+ />
+ ) : (
+
+ {visible.map((item) => (
+ navigate(editPath(item.id))}
+ onDelete={() => handleDelete(item.id)}
+ />
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default MediaLibrary;
diff --git a/frontend/src/pages/MediaPlayer.tsx b/frontend/src/pages/MediaPlayer.tsx
index f1dbed6..64b02c2 100644
--- a/frontend/src/pages/MediaPlayer.tsx
+++ b/frontend/src/pages/MediaPlayer.tsx
@@ -1,13 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
+import {
+ ArrowLeft, Lock, Maximize, Minimize, Pause, Play,
+ RotateCcw, RotateCw, Volume1, Volume2, VolumeX,
+} from "lucide-react";
+import clsx from "clsx";
+
import { AuthError } from "../utils/api/client.ts";
-import Box from "../components/Box.tsx";
+import Card from "../components/ui/Card.tsx";
+import Button from "../components/ui/Button.tsx";
+import EmptyState from "../components/ui/EmptyState.tsx";
+import Spinner from "../components/ui/Spinner.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
// Both VoDs and clips share the same on-screen shape.
type MediaItem = {
title: string,
+ description?: string,
createdAt: string,
};
@@ -21,6 +30,17 @@ type MediaPlayerProps = {
fetchDetails: (id: string) => Promise,
};
+const ControlButton = ({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) => (
+
+);
+
const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const { id } = useParams();
const navigate = useNavigate();
@@ -98,11 +118,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
- document.fullscreenElement
- ? document.exitFullscreen()
- : containerRef.current.requestFullscreen();
+ if (document.fullscreenElement) document.exitFullscreen();
+ else containerRef.current.requestFullscreen();
}, []);
+ const skip = useCallback((seconds: number) => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.currentTime = Math.min(video.duration || 0, Math.max(0, video.currentTime + seconds));
+ showControls();
+ }, [showControls]);
+
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -113,18 +139,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
- video.paused ? video.play() : video.pause();
+ if (video.paused) video.play().catch(() => {});
+ else video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
- video.currentTime = Math.max(0, video.currentTime - 5);
- showControls();
+ skip(-5);
break;
case 'ArrowRight':
e.preventDefault();
- video.currentTime = Math.min(video.duration, video.currentTime + 5);
- showControls();
+ skip(5);
break;
case 'KeyM':
video.muted = !video.muted;
@@ -137,7 +162,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
- }, [showControls, toggleFullscreen]);
+ }, [showControls, toggleFullscreen, skip]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
@@ -191,7 +216,8 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
- video.paused ? video.play() : video.pause();
+ if (video.paused) video.play().catch(() => {});
+ else video.pause();
showControls();
};
@@ -201,39 +227,37 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (notAuthenticated) {
return (
-
-
-
-
-
-
Not authenticated
-
You don't have access to this {noun.toLowerCase()}.
-
-
+
+ }
+ title="Not authenticated"
+ description={`You don't have access to this ${noun.toLowerCase()}.`}
+ action={
+ } onClick={() => navigate(-1)}>
+ Go back
+
+ }
+ />
);
}
return (
-
-
+
{/* Player */}
isPlaying && setControlsVisible(false)}
onClick={togglePlay}
@@ -241,7 +265,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
>
{
{/* Buffering spinner */}
{isWaiting && mediaUrl && (
-
-
+
+
+
+ )}
+
+ {/* Big centre play badge while paused */}
+ {!isPlaying && mediaLoaded && (
+
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
-
- {/* Gradient */}
-
+
+
{/* Controls bar — pointer-events-auto only here */}
e.stopPropagation()}
>
{/* Scrub bar */}
{ setIsDragging(true); seekTo(e.clientX); }}
>
-
-
+
+
{/* Buttons row */}
-
-
- {isPlaying
- ?
- : }
-
+
+
+ {isPlaying ? : }
+
+
+
skip(-5)}>
+
+
+
+
skip(5)}>
+
+
{/* Volume — slider expands on hover */}
-
-
-
-
-
+
-
+
{formatTime(currentTime)} / {formatTime(duration)}
-
- {isFullscreen ? : }
-
+
+ {isFullscreen ? : }
+
- {error &&
{error}
}
- {!mediaLoaded && !error && (
-
+ {error && (
+
+ {error}
+
)}
-
- {item?.title || "(No Title)"}
- {timeAgo}
-
+ {!mediaLoaded && !error && (
+
+
+ Loading {noun.toLowerCase()}…
+
+ )}
+
+
+ {item?.title || "Untitled"}
+ {timeAgo}
+ {item?.description && (
+
+ {item.description}
+
+ )}
+
);
};
diff --git a/frontend/src/pages/MyClips.tsx b/frontend/src/pages/MyClips.tsx
deleted file mode 100644
index 5b95f37..0000000
--- a/frontend/src/pages/MyClips.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import VideoCard from "../components/video/VideoCard";
-import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
-import { getClips, deleteClip } from "../utils/api/clips";
-import type { Clip } from "../utils/types";
-
-const MyClips = () => {
- const [clips, setClips] = useState
([]);
- const [error, setError] = useState(null);
- const navigate = useNavigate();
-
- useEffect(() => {
- getClips()
- .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
- .catch((err) => setError(err));
- }, []);
-
- const handleDelete = async (id: number) => {
- try {
- await deleteClip(id);
- setClips(prev => prev.filter(c => c.id !== id));
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to delete clip");
- }
- };
-
- return (
-
-
My Clips
-
-
- {clips.map((clip) => (
- navigate(`/clips/${clip.id}/edit`)}
- onDelete={() => handleDelete(clip.id)}
- />
- ))}
-
-
- {clips.length === 0 && !error && (
-
-
No clips yet.
-
Upload your first clip to get started.
-
- )}
-
- {error &&
{error.toString()}
}
-
- );
-}
-
-export default MyClips;
diff --git a/frontend/src/pages/MyVods.tsx b/frontend/src/pages/MyVods.tsx
deleted file mode 100644
index 54a0a70..0000000
--- a/frontend/src/pages/MyVods.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import VideoCard from "../components/video/VideoCard";
-import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
-import { getVods, deleteVod } from "../utils/api/vods";
-import type { Vod } from "../utils/types";
-
-const MyVods = () => {
- const [vods, setVods] = useState([]);
- const [error, setError] = useState(null);
- const navigate = useNavigate();
-
- useEffect(() => {
- getVods()
- .then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
- .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
- }, []);
-
- const handleDelete = async (id: number) => {
- try {
- await deleteVod(id);
- setVods(prev => prev.filter(v => v.id !== id));
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to delete vod");
- }
- };
-
- return (
-
-
My VoDs
-
-
- {vods.map((vod) => (
- navigate(`/vods/${vod.id}/edit`)}
- onDelete={() => handleDelete(vod.id)}
- />
- ))}
-
-
- {vods.length === 0 && !error && (
-
-
No VoDs yet.
-
Your stream recordings will appear here.
-
- )}
-
- {error &&
{error}
}
-
- );
-};
-
-export default MyVods;
diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx
new file mode 100644
index 0000000..2679eae
--- /dev/null
+++ b/frontend/src/pages/NotFound.tsx
@@ -0,0 +1,18 @@
+import { Link } from "react-router-dom";
+import { Home as HomeIcon } from "lucide-react";
+import Button from "../components/ui/Button.tsx";
+
+const NotFound = () => (
+
+
404
+
Nothing here.
+
+ That page doesn't exist — it may have been renamed, or the clip was deleted.
+
+
+
}>Back home
+
+
+);
+
+export default NotFound;
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx
index 156a560..bc813d7 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -1,17 +1,34 @@
-import { useState, useEffect } from "react";
-import { Eye, EyeOff, Copy, Check } from "lucide-react";
-import Box from "../components/Box.tsx";
+import { useEffect, useState } from "react";
+import { Check, Copy, Eye, EyeOff, KeyRound, Monitor, Moon, Sun, UserCircle2 } from "lucide-react";
+import clsx from "clsx";
+
+import Card, { CardHeader } from "../components/ui/Card.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+import EmptyState from "../components/ui/EmptyState.tsx";
+import IconButton from "../components/ui/IconButton.tsx";
+import Skeleton from "../components/ui/Skeleton.tsx";
import { getUser } from "../utils/api/users.ts";
-import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts";
+import { useTheme, type Theme } from "../lib/theme.ts";
+import type { User } from "../utils/types.ts";
+
+const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [
+ { value: "light", label: "Light", icon: Sun },
+ { value: "dark", label: "Dark", icon: Moon },
+];
const Profile = () => {
const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
+ const { theme, setTheme } = useTheme();
useEffect(() => {
- getUser().then(setUser).catch(console.error);
+ getUser()
+ .then(setUser)
+ .catch(console.error)
+ .finally(() => setLoading(false));
}, []);
const handleCopy = () => {
@@ -21,64 +38,118 @@ const Profile = () => {
setTimeout(() => setCopied(false), 2000);
};
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
if (!user) {
return (
-
- Please log in to view your profile.
+
+ }
+ title="You're signed out"
+ description="Sign in with Google from the top bar to see your profile and stream key."
+ />
);
}
return (
-
-
Profile
+
+
-
-
-
-
{user.name}
-
@{user.username}
-
{user.email}
-
- Member since {formatLocalDate(user.createdAt)}
-
+
+ {/* Warm banner echoing the brand palette */}
+
+
+

+
+
{user.name}
+
@{user.username}
+
-
-
- Stream Key
-
- 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.
-
+
+
+
- Email
+ - {user.email}
+
+
+
- Member since
+ - {formatLocalDate(user.createdAt)}
+
+
+
-
-
- {keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
+
+ }
+ title="Stream key"
+ description="Use this in OBS or any RTMP-compatible encoder. Anyone with this key can stream to your account — keep it private."
+ />
+
+
+
+
+ {keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 40))}
+
-
setKeyVisible(v => !v)}
- title={keyVisible ? "Hide key" : "Show key"}
- className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
+ setKeyVisible((v) => !v)}
+ className="border border-line bg-surface"
>
{keyVisible ? : }
-
+
-
- {copied ? : }
-
+ {copied ?
:
}
+
-
+
+
+
+ }
+ title="Appearance"
+ description="Choose how the interface looks on this device."
+ />
+
+
+ {themeOptions.map(({ value, label, icon: Icon }) => (
+ setTheme(value)}
+ className={clsx(
+ "flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all duration-150",
+ theme === value
+ ? "border-accent bg-accent/8 text-fg shadow-ambient"
+ : "border-line text-muted hover:border-line-strong hover:bg-surface-hover",
+ )}
+ >
+
+ {label}
+ {theme === value && }
+
+ ))}
+
+
);
};
diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx
index 480ee20..9a23420 100644
--- a/frontend/src/pages/StreamControl.tsx
+++ b/frontend/src/pages/StreamControl.tsx
@@ -1,8 +1,16 @@
import { useEffect, useState } from "react";
-import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
+import { Activity, Bookmark, Check, Film, Radio, Scissors, X } from "lucide-react";
import clsx from "clsx";
-import Box from "../components/Box.tsx";
-import BlueButton from "../components/buttons/BlueButton.tsx";
+
+import Card, { CardHeader } from "../components/ui/Card.tsx";
+import Button from "../components/ui/Button.tsx";
+import PageHeader from "../components/ui/PageHeader.tsx";
+import EmptyState from "../components/ui/EmptyState.tsx";
+import Progress from "../components/ui/Progress.tsx";
+import Spinner from "../components/ui/Spinner.tsx";
+import { LiveDot } from "../components/ui/Badge.tsx";
+import { Field, Input, Textarea } from "../components/ui/Field.tsx";
+
import { getUser } from "../utils/api/users.ts";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
@@ -11,9 +19,6 @@ import { getJob } from "../utils/api/jobs.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
-const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
-const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
-
type TrackedJob = {
uuid: string;
label: string;
@@ -22,38 +27,35 @@ type TrackedJob = {
errorOutput: string | null;
};
-const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
-
-);
-
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
-
-
-
{job.label} failed: {job.errorOutput ?? "Unknown error"}
+
+
+
+
+
{job.label} failed: {job.errorOutput ?? "Unknown error"}
);
}
+
if (job.state === 'SUCCEEDED') {
return (
-
-
-
{job.label} saved
+
+
+
+
+
{job.label} saved
);
}
+
return (
-
-
-
-
{job.label}…
-
+
);
@@ -214,6 +216,7 @@ const StreamControl = () => {
}
};
+ // Tapping cycles a marker through start → end → cleared.
const handleChipClick = (id: number) => {
const idStr = String(id);
setMarkerSaveError(null);
@@ -226,8 +229,12 @@ const StreamControl = () => {
if (!user) {
return (
-
- Please log in to control your stream.
+
+ }
+ title="You're signed out"
+ description="Sign in with Google from the top bar to control your stream and save clips."
+ />
);
}
@@ -240,156 +247,223 @@ const StreamControl = () => {
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
return (
-
-
Stream Control
+
+
- {/* Hero status bar */}
-
-
-
- {isStreaming && }
-
-
+ {/* Status hero */}
+
+
+
-
{isStreaming ? "Live" : "Offline"}
+
+ {isStreaming ? "Live" : "Offline"}
+
{isStreaming && streamDetails?.startDate ? (
-
Started {formatLocalDate(streamDetails.startDate)}
+
+ Started {formatLocalDate(streamDetails.startDate, {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
) : !isStreaming ? (
-
Point OBS at your stream key (see Profile) to go live.
+
+ Point OBS at your stream key — it's on your profile — to go live.
+
) : null}
{isStreaming && elapsed ? (
-
{elapsed}
-
elapsed
+
{elapsed}
+
elapsed
) : (
-
+
)}
-
+
{isStreaming && (
<>
- {/* Quick mark toolbar */}
-
-
-
-
setMarkerMessage(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
- placeholder="What just happened?"
- className={inputClass}
- />
-
- Add Marker
-
+ {/* Marker composer */}
+
+
+
+
+ setMarkerMessage(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
+ placeholder="What just happened?"
+ />
+
+
+ Add marker
+
- {markerError && {markerError}
}
+
+ {markerError && {markerError}
}
{markers.length > 0 && (
-
+
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
return (
-
+
handleChipClick(m.id)}
className={clsx(
- "flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
- isStart && "border-primary bg-primary/5 ring-1 ring-primary",
- isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
- !isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
+ "flex flex-col items-start gap-0.5 rounded-xl border px-3 py-2 text-left",
+ "transition-all duration-150",
+ isStart && "border-accent bg-accent/8 ring-1 ring-accent/40",
+ isEnd && "border-kraft bg-kraft/12 ring-1 ring-kraft/40",
+ !isStart && !isEnd && "border-line bg-sunken hover:border-line-strong hover:bg-surface-hover",
)}
>
- {m.message}
-
+
+ {m.message}
+
+
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
- {isStart && · start}
- {isEnd && · end}
+ {isStart && · start}
+ {isEnd && · end}
{ e.stopPropagation(); handleDeleteMarker(m.id); }}
+ aria-label="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-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
+ className="absolute -right-1.5 -top-1.5 hidden h-5 w-5 items-center justify-center rounded-full bg-line-strong text-inverted transition-colors duration-150 hover:bg-danger hover:text-white group-hover:flex"
>
-
+
);
})}
)}
-
+
- {/* Forefront actions */}
-
-
-
-
-
- setClipDuration(Number(e.target.value))}
- className={inputClass}
- />
-
- setClipTitle(e.target.value)} className={inputClass} />
-
+ {/* Save actions */}
+
+
+ }
+ title="Clip the last few seconds"
+ description="Grabs the tail end of the live buffer."
+ />
+
+
+ {(id) => (
+ setClipDuration(Number(e.target.value))}
+ />
+ )}
+
+
+ setClipTitle(e.target.value)}
+ />
+
+
+
+ }
+ title="Save between two markers"
+ description="Tap markers above to set the in and out points."
+ />
-
-
{markers.length < 2 ? (
- Add at least two markers above to save a section between them.
+
+ Add at least two markers above to save a section between them.
+
) : (
<>
-
+
- {startMarker ? startMarker.message : "Tap a marker to set start"}
+ {startMarker ? startMarker.message : "Set a start"}
- →
+ →
- {endMarker ? endMarker.message : "Tap a marker to set end"}
+ {endMarker ? endMarker.message : "Set an end"}
-
setMarkerSaveTitle(e.target.value)} className={inputClass} />
-
-
-
+
+ } title="Recent actions" />
{jobs.length === 0 ? (
- Clip and save actions will show up here.
+ Clip and save actions will show up here.
) : (
-
+
{jobs.map((job) => )}
)}
-
+
>
)}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index bb793db..4ac281d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,13 +1,12 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
-import flowbiteReact from "flowbite-react/plugin/vite";
export default defineConfig(({ mode }) => { // change to function form
const env = loadEnv(mode, process.cwd(), '') // load all env vars
return {
- plugins: [react(), tailwindcss(), flowbiteReact()],
+ plugins: [react(), tailwindcss()],
preview: {
port: 5173,
strictPort: true,