feat: overhaul frontend styling
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-07-29 10:16:09 +01:00
parent df8afcb428
commit 761f93e2a7
54 changed files with 2758 additions and 2195 deletions

View File

@@ -4,7 +4,32 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/small_icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<meta name="description" content="Record, trim, and export your stream clips." />
<meta name="theme-color" content="#faf9f5" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#262624" media="(prefers-color-scheme: dark)" />
<title>VoD System</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=Source+Serif+4:opsz,wght@8..60,400..600&display=swap"
/>
<script>
// Apply the stored theme before first paint so there is no light flash.
(function () {
try {
var stored = localStorage.getItem("vod-theme");
var dark = stored
? stored === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);
} catch (e) {
/* localStorage unavailable — fall back to the light theme */
}
})();
</script>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,13 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"postinstall": "flowbite-react patch"
"preview": "vite preview"
},
"dependencies": {
"@react-oauth/google": "^0.12.2",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"dotenv": "^16.5.0",
"flowbite-react": "^0.11.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0",
"path": "^0.12.7",

View File

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

View File

@@ -1,47 +1,97 @@
// App.jsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import ClipUpload from './pages/ClipUpload';
import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home';
import {useEffect} from "react";
import MyClips from './pages/MyClips';
import MyVods from './pages/MyVods';
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { GoogleOAuthProvider } from "@react-oauth/google";
import { Film, Video } from "lucide-react";
import ThemeProvider from "./components/ThemeProvider.tsx";
import MainLayout from "./layouts/MainLayout.tsx";
import Home from "./pages/Home.tsx";
import ClipUpload from "./pages/ClipUpload.tsx";
import ClipEdit from "./pages/ClipEdit.tsx";
import MediaLibrary from "./pages/MediaLibrary.tsx";
import MediaPlayer from "./pages/MediaPlayer.tsx";
import { getVodById, getVodMediaUrl } from "./utils/api/vods.ts";
import { getClipById, getClipMediaUrl } from "./utils/api/clips.ts";
import EditClip from "./pages/EditClip.tsx";
import EditVod from "./pages/EditVod.tsx";
import EditMedia from "./pages/EditMedia.tsx";
import Profile from "./pages/Profile.tsx";
import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
import NotFound from "./pages/NotFound.tsx";
import { getClips, getClipById, getClipMediaUrl, patchClip, deleteClip } from "./utils/api/clips.ts";
import { getVods, getVodById, getVodMediaUrl, patchVod, deleteVod } from "./utils/api/vods.ts";
function App() {
useEffect(() => {
document.title = "VoD System";
}, []);
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/my-vods" element={<MyVods />} />
<Route path="/video/:id" element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />} />
<Route path="/vod/:id" element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/vods/:id/edit" element={<EditVod />} />
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
</Route>
</Routes>
</Router>
</ GoogleOAuthProvider>
<ThemeProvider>
<Router>
<Routes>
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} />
<Route
path="/my-clips"
element={
<MediaLibrary
title="Clips"
description="Everything you've trimmed and saved."
noun="clip"
fetchAll={getClips}
remove={deleteClip}
editPath={(id) => `/clips/${id}/edit`}
playerPath="/video"
mediaApiPath="/api/v1/clips"
emptyIcon={<Film size={20} />}
emptyTitle="No clips yet"
emptyDescription="Upload a video or clip a moment from a live stream to get started."
/>
}
/>
<Route
path="/my-vods"
element={
<MediaLibrary
title="VoDs"
description="Full recordings of your past streams."
noun="vod"
fetchAll={getVods}
remove={deleteVod}
editPath={(id) => `/vods/${id}/edit`}
playerPath="/vod"
mediaApiPath="/api/v1/vods"
emptyIcon={<Video size={20} />}
emptyTitle="No VoDs yet"
emptyDescription="Recordings appear here once you've finished a stream."
/>
}
/>
<Route
path="/video/:id"
element={<MediaPlayer noun="Clip" getMediaUrl={getClipMediaUrl} fetchDetails={getClipById} />}
/>
<Route
path="/vod/:id"
element={<MediaPlayer noun="VoD" getMediaUrl={getVodMediaUrl} fetchDetails={getVodById} />}
/>
<Route
path="/clips/:id/edit"
element={<EditMedia noun="Clip" fetchDetails={getClipById} save={patchClip} />}
/>
<Route
path="/vods/:id/edit"
element={<EditMedia noun="VoD" fetchDetails={getVodById} save={patchVod} />}
/>
<Route path="/profile" element={<Profile />} />
<Route path="/stream" element={<StreamControl />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</ThemeProvider>
</GoogleOAuthProvider>
);
}

View File

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

@@ -1,56 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx";
type DropdownItemProps = {
item: string;
onClick: (item: string) => void;
className?: string;
};
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)}
>
{item}
</li>
);
type DropdownProps = {
label: React.ReactNode;
children: React.ReactNode;
className?: string;
};
const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{children}
</ul>
)}
</div>
);
};
export { Dropdown, DropdownItem };

View File

@@ -1,16 +0,0 @@
import React from "react";
type Props = {
children: React.ReactNode;
label: string;
};
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-gray-600">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" />
{children}
</div>
);
export default Selector;

View File

@@ -1,52 +1,143 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import type {User} from "../utils/types.ts";
import { Film, Home, LogOut, Plus, Radio, User as UserIcon, Video } from "lucide-react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import Menu, { MenuItem, MenuSeparator } from "./ui/Menu.tsx";
import { LiveDot } from "./ui/Badge.tsx";
type props = {
type Props = {
user: User | null;
className?: string
}
isStreaming: boolean;
onLogout: () => void;
/** Closes the mobile drawer after a navigation. */
onNavigate: () => void;
className?: string;
};
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const SidebarLink = ({ to, icon, label, end = false, trailing, onNavigate }: {
to: string;
icon: ReactNode;
label: string;
end?: boolean;
trailing?: ReactNode;
onNavigate: () => void;
}) => (
<NavLink to={to} end={end} onClick={onNavigate}>
{({ isActive }) => (
<span
className={clsx(
"group flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[13.5px] font-medium",
"transition-colors duration-150",
isActive
? "bg-surface text-fg shadow-ambient"
: "text-muted hover:bg-surface/60 hover:text-fg",
)}
>
<span className={clsx("shrink-0 transition-colors", isActive ? "text-accent" : "text-subtle group-hover:text-muted")}>
{icon}
</span>
<span className="truncate">{label}</span>
{trailing && <span className="ml-auto flex items-center">{trailing}</span>}
</span>
)}
</NavLink>
);
const SectionLabel = ({ children }: { children: ReactNode }) => (
<p className="px-2.5 pt-5 pb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">
{children}
</p>
);
const Sidebar = ({ user, isStreaming, onLogout, onNavigate, className }: Props) => {
const navigate = useNavigate();
const Sidebar = ({user, className}: props) => {
return (
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
<div className="px-4 pt-5 pb-3">
<img
className="w-36 mx-auto"
src="../../public/logo.png"
alt="VoD System Logo"
/>
<aside className={clsx("flex h-full flex-col overflow-hidden border-r border-line bg-panel", className)}>
<div className="flex w-[264px] flex-1 flex-col overflow-y-auto">
<div className="px-4 pt-5 pb-2">
<Link to="/" onClick={onNavigate} className="inline-flex items-center gap-2.5">
<img src="/logo.png" alt="VoD System" className="h-8 w-auto object-contain" />
</Link>
</div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<nav className="flex flex-1 flex-col px-3 pb-3">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
<SidebarLink to="/" end icon={<Home size={17} />} label="Home" onNavigate={onNavigate} />
<SidebarLink to="/create" icon={<Plus size={17} />} label="New clip" onNavigate={onNavigate} />
</div>
{user && (
<>
<Divider />
<SectionLabel>Library</SectionLabel>
<div className="flex flex-col gap-0.5">
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
<SidebarLink
to="/stream"
icon={<Radio size={17} />}
label="Stream"
onNavigate={onNavigate}
trailing={isStreaming ? <LiveDot /> : undefined}
/>
<SidebarLink to="/my-clips" icon={<Film size={17} />} label="Clips" onNavigate={onNavigate} />
<SidebarLink to="/my-vods" icon={<Video size={17} />} label="VoDs" onNavigate={onNavigate} />
</div>
</>
)}
<div className="flex-1" />
{user && (
<>
<Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</nav>
</aside>
{user && (
<div className="border-t border-line p-3">
<Menu
align="left"
placement="top"
className="w-full"
menuClassName="w-[232px]"
trigger={(open) => (
<span
className={clsx(
"flex w-full items-center gap-2.5 rounded-[10px] p-1.5 text-left transition-colors duration-150",
open ? "bg-surface" : "hover:bg-surface/70",
)}
>
<img
src={user.profilePictureUrl}
alt=""
referrerPolicy="no-referrer"
className="h-7 w-7 shrink-0 rounded-full ring-1 ring-line"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-fg">{user.name}</span>
<span className="block truncate text-[11.5px] text-subtle">@{user.username}</span>
</span>
</span>
)}
>
{(close) => (
<>
<MenuItem
icon={<UserIcon size={14} />}
onClick={() => { close(); onNavigate(); navigate("/profile"); }}
>
Profile
</MenuItem>
<MenuSeparator />
<MenuItem
icon={<LogOut size={14} />}
tone="danger"
onClick={() => { close(); onLogout(); }}
>
Log out
</MenuItem>
</>
)}
</Menu>
</div>
)}
</div>
</aside>
);
};

View File

@@ -0,0 +1,26 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { readInitialTheme, THEME_STORAGE_KEY, ThemeContext, type Theme } from "../lib/theme.ts";
const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setThemeState] = useState<Theme>(readInitialTheme);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
document.documentElement.classList.toggle("dark", next === "dark");
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
/* Private mode or blocked storage — the theme just won't persist. */
}
}, []);
const value = useMemo(() => ({
theme,
setTheme,
toggleTheme: () => setTheme(theme === "dark" ? "light" : "dark"),
}), [theme, setTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export default ThemeProvider;

View File

@@ -1,103 +1,69 @@
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
import { PanelLeft, PanelLeftClose } from "lucide-react";
import { GoogleLogin } from "@react-oauth/google";
import type { CredentialResponse } from "@react-oauth/google";
import { Link } from "react-router-dom";
import IconButton from "./ui/IconButton.tsx";
import ThemeToggle from "./ui/ThemeToggle.tsx";
import Badge, { LiveDot } from "./ui/Badge.tsx";
import { useTheme } from "../lib/theme.ts";
import type { User } from "../utils/types.ts";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
type Props = {
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
sidebarOpen: boolean;
onToggleSidebar: () => void;
onLogin: (response: CredentialResponse) => void;
className?: string;
}
};
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
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(() => {
fetchUser();
navigate("/");
})
.catch((error) => {
console.error("Logout failed:", error);
});
}
const Topbar = ({ user, isStreaming, sidebarOpen, onToggleSidebar, onLogin, className }: Props) => {
const { theme } = useTheme();
return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div>
<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-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
<header
className={clsx(
"flex h-14 shrink-0 items-center gap-3 border-b border-line bg-canvas/85 px-3 backdrop-blur-md",
className,
)}
</div>
)
}
>
<IconButton
label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
onClick={onToggleSidebar}
>
{sidebarOpen ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
</IconButton>
<div className="flex-1" />
{user && isStreaming && (
<Link to="/stream" className="hidden sm:block">
<Badge tone="live" className="cursor-pointer transition-colors hover:bg-live/20">
<LiveDot size={7} />
Live now
</Badge>
</Link>
)}
<ThemeToggle />
{!user && (
<div className="flex items-center">
<GoogleLogin
shape="pill"
size="medium"
text="signin"
theme={theme === "dark" ? "filled_black" : "outline"}
useOneTap={false}
onSuccess={onLogin}
onError={() => console.error("Google login failed")}
/>
</div>
)}
</header>
);
};
export default Topbar;

View File

@@ -1,14 +0,0 @@
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-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props}
/>
);
};
export default MenuButton;

View File

@@ -1,23 +0,0 @@
import MenuButton from "./MenuButton.tsx";
import { Link, useLocation } from "react-router-dom";
type Props = {
url: string;
logo: React.ReactNode;
label: string;
}
const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
return (
<Link className="w-full" to={url}>
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
{logo}{label}
</MenuButton>
</Link>
)
}
export default SidebarButton;

View File

@@ -0,0 +1,45 @@
import clsx from "clsx";
import type { ReactNode } from "react";
type Tone = "neutral" | "accent" | "live" | "success" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-sunken text-muted border-line",
accent: "bg-accent/12 text-accent-ink border-accent/25",
live: "bg-live/12 text-live border-live/25",
success: "bg-success/12 text-success border-success/25",
danger: "bg-danger/12 text-danger border-danger/25",
};
type Props = {
children: ReactNode;
tone?: Tone;
className?: string;
};
const Badge = ({ children, tone = "neutral", className }: Props) => (
<span
className={clsx(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium",
tones[tone],
className,
)}
>
{children}
</span>
);
/** Pulsing dot used to signal an active stream. */
export const LiveDot = ({ active = true, size = 8 }: { active?: boolean; size?: number }) => (
<span className="relative flex shrink-0" style={{ height: size, width: size }}>
{active && (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-live opacity-75" />
)}
<span
className={clsx("relative inline-flex rounded-full", active ? "bg-live" : "bg-line-strong")}
style={{ height: size, width: size }}
/>
</span>
);
export default Badge;

View File

@@ -0,0 +1,63 @@
import clsx from "clsx";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import Spinner from "./Spinner.tsx";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
size?: Size;
loading?: boolean;
/** Rendered before the label. Hidden while `loading` so the spinner takes its place. */
icon?: ReactNode;
fullWidth?: boolean;
};
const variants: Record<Variant, string> = {
primary:
"bg-accent text-accent-fg shadow-ambient hover:bg-accent-hover active:translate-y-px",
secondary:
"bg-surface text-fg border border-line-strong hover:bg-surface-hover active:translate-y-px",
ghost:
"text-muted hover:bg-surface-hover hover:text-fg",
danger:
"bg-danger text-white shadow-ambient hover:bg-danger-hover active:translate-y-px",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-[13px] gap-1.5 rounded-lg",
md: "h-10 px-4 text-sm gap-2 rounded-[10px]",
lg: "h-11 px-5 text-[15px] gap-2 rounded-xl",
};
const Button = ({
variant = "primary",
size = "md",
loading = false,
icon,
fullWidth = false,
className,
children,
disabled,
...props
}: Props) => (
<button
className={clsx(
"inline-flex items-center justify-center font-medium whitespace-nowrap",
"transition-[background-color,color,border-color,transform,opacity] duration-150 ease-snappy",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
fullWidth && "w-full",
className,
)}
disabled={disabled || loading}
{...props}
>
{loading ? <Spinner size={size === "sm" ? 13 : 15} /> : icon}
{children}
</button>
);
export default Button;

View File

@@ -0,0 +1,47 @@
import clsx from "clsx";
import type { HTMLAttributes, ReactNode } from "react";
type Props = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
/** `flat` drops the shadow — for cards sitting inside another surface. */
tone?: "raised" | "flat";
};
const Card = ({ children, tone = "raised", className, ...props }: Props) => (
<div
className={clsx(
"rounded-2xl border border-line bg-surface",
tone === "raised" && "shadow-ambient",
className,
)}
{...props}
>
{children}
</div>
);
type HeaderProps = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
className?: string;
};
/** Icon + title + optional supporting copy, the standard card lead-in. */
export const CardHeader = ({ icon, title, description, action, className }: HeaderProps) => (
<div className={clsx("flex items-start gap-3", className)}>
{icon && (
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg">{title}</h2>
{description && <p className="mt-0.5 text-[13px] leading-relaxed text-muted">{description}</p>}
</div>
{action}
</div>
);
export default Card;

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import clsx from "clsx";
type Props = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
className?: string;
};
const EmptyState = ({ icon, title, description, action, className }: Props) => (
<div
className={clsx(
"flex flex-col items-center justify-center rounded-2xl border border-dashed border-line-strong",
"px-6 py-16 text-center",
className,
)}
>
{icon && (
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-sunken text-subtle">
{icon}
</div>
)}
<p className="text-[15px] font-medium text-fg">{title}</p>
{description && <p className="mt-1 max-w-sm text-sm text-muted">{description}</p>}
{action && <div className="mt-5">{action}</div>}
</div>
);
export default EmptyState;

View File

@@ -0,0 +1,42 @@
import clsx from "clsx";
import { useId, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
type FieldProps = {
label?: string;
hint?: string;
error?: string | null;
children: (id: string) => ReactNode;
className?: string;
};
/** Label + control + hint/error, wired together with a generated id. */
export const Field = ({ label, hint, error, children, className }: FieldProps) => {
const id = useId();
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
{label && (
<label htmlFor={id} className="text-[13px] font-medium text-fg">
{label}
</label>
)}
{children(id)}
{error ? (
<p className="text-[13px] text-danger">{error}</p>
) : hint ? (
<p className="text-[13px] text-muted">{hint}</p>
) : null}
</div>
);
};
export const Input = ({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) => (
<input className={clsx("field", className)} {...props} />
);
export const Textarea = ({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea className={clsx("field resize-none", className)} {...props} />
);
export const Select = ({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) => (
<select className={clsx("field cursor-pointer", className)} {...props} />
);

View File

@@ -0,0 +1,27 @@
import clsx from "clsx";
import type { ButtonHTMLAttributes } from "react";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
/** Required — icon-only buttons need an accessible name. */
label: string;
size?: "sm" | "md";
active?: boolean;
};
const IconButton = ({ label, size = "md", active = false, className, ...props }: Props) => (
<button
aria-label={label}
title={label}
className={clsx(
"inline-flex items-center justify-center rounded-lg transition-colors duration-150",
"text-muted hover:bg-surface-hover hover:text-fg",
"disabled:pointer-events-none disabled:opacity-40",
active && "bg-surface-hover text-fg",
size === "sm" ? "h-8 w-8" : "h-9 w-9",
className,
)}
{...props}
/>
);
export default IconButton;

View File

@@ -0,0 +1,95 @@
import clsx from "clsx";
import { useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode } from "react";
type MenuProps = {
/** Receives the open state so triggers can render a caret or highlight. */
trigger: (open: boolean) => ReactNode;
children: ReactNode | ((close: () => void) => ReactNode);
align?: "left" | "right";
/** Opens downward by default; `top` flips it above the trigger. */
placement?: "bottom" | "top";
className?: string;
menuClassName?: string;
};
const Menu = ({ trigger, children, align = "right", placement = "bottom", className, menuClassName }: MenuProps) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div ref={ref} className={clsx("relative", className)}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
}}
className="block"
>
{trigger(open)}
</button>
{open && (
<div
role="menu"
className={clsx(
"absolute z-50 min-w-44 animate-pop overflow-hidden rounded-xl",
"border border-line bg-surface p-1 shadow-overlay",
align === "right" ? "right-0" : "left-0",
placement === "bottom"
? clsx("top-full mt-1.5", align === "right" ? "origin-top-right" : "origin-top-left")
: clsx("bottom-full mb-1.5", align === "right" ? "origin-bottom-right" : "origin-bottom-left"),
menuClassName,
)}
>
{typeof children === "function" ? children(() => setOpen(false)) : children}
</div>
)}
</div>
);
};
type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> & {
icon?: ReactNode;
tone?: "default" | "danger";
};
export const MenuItem = ({ icon, tone = "default", className, children, ...props }: ItemProps) => (
<button
type="button"
role="menuitem"
className={clsx(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] font-medium",
"transition-colors duration-100",
tone === "danger"
? "text-danger hover:bg-danger/10"
: "text-fg hover:bg-surface-hover",
className,
)}
{...props}
>
{icon}
{children}
</button>
);
export const MenuSeparator = () => <div className="my-1 h-px bg-line" />;
export default Menu;

View File

@@ -0,0 +1,23 @@
import clsx from "clsx";
import type { ReactNode } from "react";
type Props = {
title: string;
description?: string;
action?: ReactNode;
className?: string;
};
const PageHeader = ({ title, description, action, className }: Props) => (
<header className={clsx("flex flex-wrap items-end justify-between gap-4", className)}>
<div>
<h1 className="font-serif text-[28px] leading-tight font-normal tracking-tight text-fg">
{title}
</h1>
{description && <p className="mt-1.5 text-sm text-muted">{description}</p>}
</div>
{action}
</header>
);
export default PageHeader;

View File

@@ -0,0 +1,31 @@
import clsx from "clsx";
type Props = {
/** 0–1. Values outside the range are clamped. */
value: number;
className?: string;
indeterminate?: boolean;
};
const Progress = ({ value, className, indeterminate = false }: Props) => {
const pct = Math.round(Math.min(1, Math.max(0, value)) * 100);
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : pct}
className={clsx("h-1.5 w-full overflow-hidden rounded-full bg-sunken", className)}
>
<div
className={clsx(
"h-full rounded-full bg-accent transition-[width] duration-300 ease-fluid",
indeterminate && "animate-shimmer",
)}
style={{ width: indeterminate ? "100%" : `${pct}%` }}
/>
</div>
);
};
export default Progress;

View File

@@ -0,0 +1,7 @@
import clsx from "clsx";
const Skeleton = ({ className }: { className?: string }) => (
<div className={clsx("animate-shimmer rounded-lg bg-sunken", className)} />
);
export default Skeleton;

View File

@@ -0,0 +1,26 @@
import clsx from "clsx";
type Props = {
size?: number;
className?: string;
};
/** Ring spinner that inherits the current text colour. */
const Spinner = ({ size = 16, className }: Props) => (
<span
role="status"
aria-label="Loading"
className={clsx(
"inline-block shrink-0 rounded-full border-current border-t-transparent animate-spin",
className,
)}
style={{
width: size,
height: size,
borderWidth: Math.max(1.5, size / 8),
opacity: 0.85,
}}
/>
);
export default Spinner;

View File

@@ -0,0 +1,31 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "../../lib/theme.ts";
import IconButton from "./IconButton.tsx";
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";
return (
<IconButton
label={isDark ? "Switch to light theme" : "Switch to dark theme"}
onClick={toggleTheme}
className="relative overflow-hidden"
>
<Sun
size={17}
className={`absolute transition-all duration-300 ease-fluid ${
isDark ? "translate-y-0 rotate-0 opacity-100" : "-translate-y-5 rotate-90 opacity-0"
}`}
/>
<Moon
size={17}
className={`absolute transition-all duration-300 ease-fluid ${
isDark ? "translate-y-5 -rotate-90 opacity-0" : "translate-y-0 rotate-0 opacity-100"
}`}
/>
</IconButton>
);
};
export default ThemeToggle;

View File

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

View File

@@ -1,59 +0,0 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
};
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
};
return (
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option>
<option value="1280,720">720p</option>
<option value="854,480">480p</option>
<option value="640,360">360p</option>
</select>
</Selector>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
<option value="15">15</option>
</select>
</Selector>
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
/>
</Selector>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { type Dispatch, type SetStateAction } from "react";
import { Download, Settings2, Sparkles, TriangleAlert } from "lucide-react";
import Card, { CardHeader } from "../ui/Card.tsx";
import Button from "../ui/Button.tsx";
import Progress from "../ui/Progress.tsx";
import { Field, Input, Select, Textarea } from "../ui/Field.tsx";
import type { VideoMetadata } from "../../utils/types.ts";
const RESOLUTIONS = [
{ value: "3840,2160", label: "2160p · 4K" },
{ value: "2560,1440", label: "1440p · QHD" },
{ value: "1920,1080", label: "1080p · Full HD" },
{ value: "1280,720", label: "720p · HD" },
{ value: "854,480", label: "480p" },
{ value: "640,360", label: "360p" },
];
const FRAME_RATES = [60, 30, 15];
type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
onExport: () => void;
onDownload: () => void;
downloadable: boolean;
/** 0–1, as reported by the compression job. */
progress: number;
exporting: boolean;
error?: string | null;
};
const ExportPanel = ({
metadata,
setMetadata,
onExport,
onDownload,
downloadable,
progress,
exporting,
error,
}: Props) => (
<div className="flex flex-col gap-4">
<Card className="p-5">
<CardHeader icon={<Sparkles size={16} />} title="Details" />
<div className="mt-5 flex flex-col gap-4">
<Field label="Title">
{(id) => (
<Input
id={id}
type="text"
value={metadata.title}
placeholder="Name this clip"
onChange={(e) => setMetadata((prev) => ({ ...prev, title: e.target.value }))}
/>
)}
</Field>
<Field label="Description">
{(id) => (
<Textarea
id={id}
rows={3}
value={metadata.description}
placeholder="Optional"
onChange={(e) => setMetadata((prev) => ({ ...prev, description: e.target.value }))}
/>
)}
</Field>
</div>
</Card>
<Card className="p-5">
<CardHeader icon={<Settings2 size={16} />} title="Export settings" />
<div className="mt-5 flex flex-col gap-4">
<Field label="Resolution">
{(id) => (
<Select
id={id}
value={`${metadata.width},${metadata.height}`}
onChange={(e) => {
const [width, height] = e.target.value.split(",").map(Number);
setMetadata((prev) => ({ ...prev, width, height }));
}}
>
{RESOLUTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</Select>
)}
</Field>
<Field label="Frame rate">
{(id) => (
<Select
id={id}
value={metadata.fps}
onChange={(e) => setMetadata((prev) => ({ ...prev, fps: Number(e.target.value) }))}
>
{FRAME_RATES.map((fps) => (
<option key={fps} value={fps}>{fps} fps</option>
))}
</Select>
)}
</Field>
<Field label="Size limit" hint="Megabytes. The encoder targets a bitrate that fits.">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={Math.round(metadata.fileSize / 1000)}
onChange={(e) => setMetadata((prev) => ({
...prev,
fileSize: Math.max(1, Number(e.target.value)) * 1000,
}))}
/>
)}
</Field>
</div>
</Card>
<Card className="p-5">
{error && (
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-danger/25 bg-danger/8 px-3.5 py-2.5 text-[13px] text-danger">
<TriangleAlert size={15} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
{downloadable ? (
<Button fullWidth size="lg" icon={<Download size={16} />} onClick={onDownload}>
Download clip
</Button>
) : (
<Button fullWidth size="lg" onClick={onExport} loading={exporting}>
{exporting ? "Compressing" : "Export clip"}
</Button>
)}
{exporting && (
<div className="mt-4">
<Progress value={progress} indeterminate={progress <= 0} />
<p className="mt-2 text-center font-mono text-[12px] tabular-nums text-subtle">
{progress > 0 ? `${Math.round(progress * 100)}%` : "Starting…"}
</p>
</div>
)}
</Card>
</div>
);
export default ExportPanel;

View File

@@ -1,53 +0,0 @@
import BlueButton from "../buttons/BlueButton.tsx";
import React from "react";
type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>;
handleDownload: React.MouseEventHandler<HTMLButtonElement>;
downloadable: boolean;
progress: number;
uploading: boolean;
}
const Spinner = () => (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
);
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return (
<div className={"flex flex-col gap-3"}>
<BlueButton
onClick={dataSend}
disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</BlueButton>
<div className="h-10 flex items-center">
{ downloadable ?
(<BlueButton className="w-full" onClick={handleDownload}>
Download
</BlueButton>)
:(
<progress
value={progress}
className="w-full h-1.5 rounded bg-gray-200">
</progress>
)
}
</div>
</div>
)
}
export default ExportWidget;

View File

@@ -1,22 +0,0 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
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>
);
export default MetadataBox;

View File

@@ -1,37 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
sliderValue: number;
setSliderValue: Dispatch<SetStateAction<number>>;
className?: string;
};
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber);
};
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={className}
/>
);
}

View File

@@ -1,74 +0,0 @@
import { useEffect, useState } from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts";
type Props = {
video: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
className?: string;
};
export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => {
if (!video) return;
if (video.paused) {
video.play();
setIsPlaying(true);
} else {
video.pause();
setIsPlaying(false);
}
};
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
useEffect(() => {
if (!video) return;
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
};
}, [video]);
return (
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className="flex gap-1">
<Icon size={24} />
<input
type="range"
min={0}
max={100}
value={volume}
onChange={updateVolume}
className="w-20"
/>
</div>
<button
onClick={togglePlay}
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<span>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Props = {
video: HTMLVideoElement | null;
duration: number;
className?: string;
};
/** Transport bar for the clip editor preview: play/pause, scrub, volume. */
const PreviewControls = ({ video, duration, className }: Props) => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
// The <video> element is the source of truth — mirror it rather than
// assuming our own actions are the only ones driving playback.
useEffect(() => {
if (!video) return;
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onTime = () => setCurrentTime(video.currentTime);
const onVolume = () => { setVolume(video.volume); setMuted(video.muted); };
video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause);
video.addEventListener("timeupdate", onTime);
video.addEventListener("seeking", onTime);
video.addEventListener("volumechange", onVolume);
return () => {
video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause);
video.removeEventListener("timeupdate", onTime);
video.removeEventListener("seeking", onTime);
video.removeEventListener("volumechange", onVolume);
};
}, [video]);
const togglePlay = () => {
if (!video) return;
if (video.paused) video.play().catch(() => {});
else video.pause();
};
const effectiveVolume = muted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
return (
<div className={clsx("flex flex-col gap-3", className)}>
<input
type="range"
min={0}
max={duration || 0}
step={0.05}
value={currentTime}
disabled={!video}
onChange={(e) => {
if (!video) return;
video.currentTime = e.target.valueAsNumber;
setCurrentTime(e.target.valueAsNumber);
}}
className="slider w-full"
aria-label="Playback position"
/>
<div className="flex items-center gap-3">
<button
onClick={togglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-fg transition-colors duration-150 hover:bg-accent-hover"
>
{isPlaying ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" className="ml-0.5" />}
</button>
<span className="font-mono text-[13px] tabular-nums text-muted">
{formatTime(currentTime)}
<span className="text-subtle"> / {formatTime(duration)}</span>
</span>
<div className="flex-1" />
<div className="flex items-center gap-2">
<button
onClick={() => { if (video) video.muted = !video.muted; }}
aria-label={muted ? "Unmute" : "Mute"}
className="text-muted transition-colors hover:text-fg"
>
<VolumeIcon size={17} />
</button>
<input
type="range"
min={0}
max={1}
step={0.02}
value={effectiveVolume}
onChange={(e) => {
if (!video) return;
video.volume = e.target.valueAsNumber;
video.muted = e.target.valueAsNumber === 0;
}}
className="slider w-24"
aria-label="Volume"
/>
</div>
</div>
</div>
);
};
export default PreviewControls;

View File

@@ -0,0 +1,68 @@
import RangeSlider from "react-range-slider-input";
import "react-range-slider-input/dist/style.css";
import { useRef } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Props = {
video: HTMLVideoElement | null;
duration: number;
range: [number, number];
onRangeChange: (range: [number, number]) => void;
className?: string;
};
const Marker = ({ label, value, tone }: { label: string; value: string; tone: "in" | "out" | "length" }) => (
<div className="flex items-baseline gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">{label}</span>
<span
className={clsx(
"font-mono text-[13px] tabular-nums",
tone === "length" ? "text-accent-ink" : "text-fg",
)}
>
{value}
</span>
</div>
);
/** Two-handle trim control. Dragging a handle scrubs the preview to it. */
const TrimRange = ({ video, duration, range, onRangeChange, className }: Props) => {
const previous = useRef<[number, number]>(range);
const handleInput = (value: number[]) => {
const next: [number, number] = [value[0], value[1]];
if (video) {
// Follow whichever handle actually moved so the frame under the
// cursor is the one being set.
if (previous.current[0] !== next[0]) video.currentTime = next[0];
else if (previous.current[1] !== next[1]) video.currentTime = next[1];
}
previous.current = next;
onRangeChange(next);
};
return (
<div className={clsx("flex flex-col gap-3", className)}>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1">
<Marker label="In" value={formatTime(range[0])} tone="in" />
<Marker label="Out" value={formatTime(range[1])} tone="out" />
<div className="flex-1" />
<Marker label="Length" value={formatTime(Math.max(0, range[1] - range[0]))} tone="length" />
</div>
<RangeSlider
id="range-slider"
min={0}
max={duration}
step={0.1}
value={range}
onInput={handleInput}
/>
</div>
);
};
export default TrimRange;

View File

@@ -1,38 +1,50 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { MenuItem, MenuSeparator } from "../ui/Menu.tsx";
type VideoCardProps = {
id: number,
title: string,
duration: number,
createdAt: string,
onEdit?: () => void,
onDelete?: () => void,
className?: string,
mediaApiPath?: string,
playerPath?: string,
itemLabel?: string,
}
id: number;
title: string;
duration: number;
createdAt: string;
onEdit?: () => void;
onDelete?: () => void;
className?: string;
mediaApiPath?: string;
playerPath?: string;
itemLabel?: string;
};
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const FALLBACK_THUMBNAIL = "/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const 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 [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
const hasMenu = Boolean(onEdit || onDelete);
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
const interval = setInterval(() => setTimeAgo(dateToTimeAgo(stringToDate(createdAt))), 1000);
return () => clearInterval(interval);
}, [createdAt]);
@@ -44,16 +56,22 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const onPointerDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
setConfirmDelete(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
// Card actions live inside the <Link>, so their clicks must not navigate.
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
e.preventDefault();
e.stopPropagation();
@@ -61,86 +79,99 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
};
return (
<Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<Link to={`${playerPath}/${id}`} className={clsx("group block", className)}>
<div className="relative overflow-hidden rounded-xl border border-line bg-sunken shadow-ambient transition-all duration-200 ease-fluid group-hover:-translate-y-0.5 group-hover:shadow-raised">
<img
src={thumbnailAvailable ? thumbnailUrl : FALLBACK_THUMBNAIL}
alt=""
loading="lazy"
className="aspect-video w-full object-cover transition-transform duration-300 ease-fluid group-hover:scale-[1.03]"
/>
<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)}
{/* Hover scrim + play affordance */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all duration-200 group-hover:bg-black/25 group-hover:opacity-100">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-white/95 text-[#1f1e1d] shadow-raised">
<Play size={17} fill="currentColor" />
</span>
</div>
{(onEdit || onDelete) && (
<div
ref={menuRef}
className="absolute top-1.5 right-1.5"
onClick={(e) => e.preventDefault()}
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>
<span className="pointer-events-none absolute bottom-2 right-2 rounded-md bg-black/70 px-1.5 py-0.5 font-mono text-[11px] font-medium tabular-nums text-white backdrop-blur-sm">
{formatTime(duration)}
</span>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil size={13} /> Edit
</button>
)}
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 size={13} /> Delete
</button>
)}
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
{hasMenu && (
<div
ref={menuRef}
className="absolute right-2 top-2"
onClick={(e) => e.preventDefault()}
>
<button
aria-label="Card actions"
onClick={(e) => stopAndRun(e, () => { setMenuOpen((v) => !v); setConfirmDelete(false); })}
className={clsx(
"flex h-7 w-7 items-center justify-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-all duration-150 hover:bg-black/80",
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
</div>
)}
</div>
>
<MoreHorizontal size={15} />
</button>
<div className="flex flex-col p-2">
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title}
</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div>
{menuOpen && (
<div className="absolute right-0 top-9 z-50 w-40 animate-pop overflow-hidden rounded-xl border border-line bg-surface p-1 shadow-overlay">
{confirmDelete ? (
<div className="p-2">
<p className="mb-2.5 text-[13px] text-fg">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 rounded-lg bg-danger px-2 py-1.5 text-[12px] font-medium text-white transition-colors hover:bg-danger-hover"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 rounded-lg bg-sunken px-2 py-1.5 text-[12px] font-medium text-muted transition-colors hover:bg-surface-hover"
>
Cancel
</button>
</div>
</div>
) : (
<>
{onEdit && (
<MenuItem
icon={<Pencil size={14} />}
onClick={(e) => stopAndRun(e, () => { setMenuOpen(false); onEdit(); })}
>
Edit details
</MenuItem>
)}
{onEdit && onDelete && <MenuSeparator />}
{onDelete && (
<MenuItem
icon={<Trash2 size={14} />}
tone="danger"
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
>
Delete
</MenuItem>
)}
</>
)}
</div>
)}
</div>
)}
</div>
<div className="px-0.5 pt-2.5">
<p className="line-clamp-2 text-[13.5px] font-medium leading-snug text-fg transition-colors group-hover:text-accent-ink">
{title === "" ? "Untitled" : title}
</p>
<p className="mt-1 text-[12px] text-subtle">{timeAgo}</p>
</div>
</Link>
);
}
};
export default VideoCard;

View File

@@ -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 <html> (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,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c6a63' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 2rem;
}
.dark select.field {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a5a29a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
}
/* 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;
}
}

View File

@@ -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 | User>(null);
const [sidebarOpen, setSidebarOpen] = useState(isDesktop);
const [user, setUser] = useState<User | null>(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 (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<div className="flex h-screen overflow-hidden bg-canvas text-fg">
{sidebarOpen && (
<div
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-30 bg-fg/25 backdrop-blur-[2px] md:hidden"
aria-hidden
/>
)}
<Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />
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",
)}
/>
<div className="flex min-w-0 flex-1 flex-col">
<Topbar
user={user}
isStreaming={isStreaming}
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen((v) => !v)}
onLogin={handleLogin}
/>
<main key={pathname} className="flex-1 animate-fade overflow-y-auto">
<Outlet context={{ user, refreshUser: fetchUser }} />
</main>
</div>
</div>
);
};
export default MainLayout;

25
frontend/src/lib/theme.ts Normal file
View File

@@ -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<ThemeContextValue | null>(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 <html>. */
export const readInitialTheme = (): Theme => {
if (typeof document === "undefined") return "light";
return document.documentElement.classList.contains("dark") ? "dark" : "light";
};

View File

@@ -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<HTMLVideoElement | null>(null);
const [videoUrl, setVideoUrl] = useState<string>('');
const [uploadedId, setUploadedId] = useState<string | null>(null);
// Held in state (not a ref) so the controls re-render once the element exists.
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
const [videoUrl, setVideoUrl] = useState("");
const [sourceDuration, setSourceDuration] = useState(0);
const [jobId, setJobId] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
const [output, setOutput] = useState<VideoMetadata>({
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<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<EmptyState
icon={<FileVideo size={22} />}
title="No video selected"
description="Pick a file first — the editor works on a video you choose from your device."
action={<Link to="/create"><Button>Choose a video</Button></Link>}
/>
</div>
);
}
return (
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded}
<div className="mx-auto w-full max-w-7xl px-6 pb-16 pt-10 sm:px-8">
<PageHeader
title="Clip editor"
description={localFile.name}
/>
<Box className={"w-4/5 h-full m-auto"}>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Box>
<div className="mt-8 grid gap-4 lg:grid-cols-[minmax(0,1fr)_370px] lg:items-start">
<div className="flex flex-col gap-4">
<Card className="overflow-hidden">
<video
ref={setVideoEl}
src={videoUrl}
className="aspect-video w-full bg-black object-contain"
onLoadedMetadata={handleMetadataLoaded}
onClick={() => videoEl && (videoEl.paused ? videoEl.play() : videoEl.pause())}
/>
</Card>
{metadata &&
<Box className={"mt-4 p-5"}>
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className={"w-full accent-primary"}
/>
<Card className="flex flex-col gap-5 p-5">
<PreviewControls video={videoEl} duration={sourceDuration} />
<div className="h-px bg-line" />
<TrimRange
video={videoEl}
duration={sourceDuration}
range={[output.startPoint, output.startPoint + output.duration]}
onRangeChange={([start, end]) =>
setOutput((prev) => ({ ...prev, startPoint: start, duration: end - start }))
}
/>
</Card>
</div>
<PlaybackSlider
videoRef={videoRef.current}
videoMetadata={metadata}
sliderValue={playbackValue}
setSliderValue={setPlaybackValue}
className={"w-full accent-primary"}
/>
<ClipRangeSlider
videoRef={videoRef.current}
videoMetadata={metadata}
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2 bg-primary"}
/>
{error && (
<div className={"text-red-600 text-center mt-2"}>
{error}
</div>
)}
</Box>
}
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}>
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
<ExportPanel
metadata={output}
setMetadata={setOutput}
onExport={handleExport}
onDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
exporting={exporting}
error={error}
/>
</Box>
</div>
</div>
);
};

View File

@@ -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<File | null>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(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<HTMLDivElement>) => {
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 (
<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">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<PageHeader
title="New clip"
description="Pick a video to trim and compress. Nothing is uploaded until you export."
/>
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/>
<Card className="mt-8 p-6">
<div
onDragOver={(e) => { 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",
)}
>
<div
className={clsx(
"mb-4 flex h-12 w-12 items-center justify-center rounded-xl transition-colors duration-200",
dragging ? "bg-accent text-accent-fg" : "bg-surface text-subtle shadow-ambient",
)}
>
<UploadCloud size={22} />
</div>
<p className="text-sm font-medium text-fg">
{dragging ? "Drop it here" : "Drag a video here, or click to browse"}
</p>
<p className="mt-1 text-[13px] text-muted">MP4, MOV, WebM, or OGG</p>
<BlueButton onClick={press}>Upload</BlueButton>
<input
ref={inputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => accept(e.target.files?.[0])}
/>
</div>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Box>
{file && (
<div className="mt-4 flex animate-rise items-center gap-3 rounded-xl border border-line bg-sunken p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent">
<FileVideo size={17} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-fg">{file.name}</p>
<p className="text-[12px] text-subtle">{formatBytes(file.size)}</p>
</div>
<button
aria-label="Remove file"
onClick={() => { setFile(null); if (inputRef.current) inputRef.current.value = ""; }}
className="flex h-7 w-7 items-center justify-center rounded-lg text-subtle transition-colors hover:bg-surface hover:text-fg"
>
<X size={15} />
</button>
</div>
)}
{error && <p className="mt-4 text-[13px] text-danger">{error}</p>}
<div className="mt-6 flex justify-end border-t border-line pt-5">
<Button onClick={handleContinue} disabled={!file} icon={<ArrowRight size={15} />}>
Continue to editor
</Button>
</div>
</Card>
</div>
)
);
};
export default ClipUpload;

View File

@@ -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<string | null>(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 (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Box>
)}
</div>
);
};
export default EditClip;

View File

@@ -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<Editable | null>;
save: (id: number, data: { title: string; description: string }) => Promise<unknown>;
};
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<string | null>(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 (
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
className="-ml-3 mb-5"
>
Back
</Button>
<PageHeader title={`Edit ${noun}`} description="Rename it or update the description." />
<Card className="mt-8 p-6">
{loading ? (
<div className="flex flex-col gap-5">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-28" />
</div>
) : (
<div className="flex flex-col gap-5">
<Field label="Title">
{(fieldId) => (
<Input
id={fieldId}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Name this ${noun.toLowerCase()}`}
/>
)}
</Field>
<Field label="Description" hint="Optional — a few words about what happens.">
{(fieldId) => (
<Textarea
id={fieldId}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description"
rows={4}
/>
)}
</Field>
{error && <p className="text-[13px] text-danger">{error}</p>}
<div className="flex items-center gap-2 border-t border-line pt-5">
<Button
onClick={handleSave}
loading={saving}
icon={saved ? <Check size={15} /> : undefined}
className="min-w-28"
>
{saving ? "Saving" : saved ? "Saved" : "Save changes"}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
Cancel
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
export default EditMedia;

View File

@@ -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<string | null>(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 (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Box>
)}
</div>
);
};
export default EditVod;

View File

@@ -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 (
<div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img
src="/logo.png"
alt="VoD System Logo"
className="h-24 mb-6 object-contain"
/>
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
A home for your clips.
</h1>
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
const Home = () => (
<div className="mx-auto w-full max-w-5xl px-6 pb-20 pt-14 sm:px-8">
{/* Hero */}
<section className="flex flex-col items-center text-center">
<img src="/logo.png" alt="" className="mb-8 h-16 w-auto object-contain" />
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div>
<div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
<h1 className="max-w-2xl font-serif text-[42px] leading-[1.1] tracking-tight text-fg sm:text-[52px]">
A home for your <span className="text-accent">clips</span>.
</h1>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
<p className="mt-5 max-w-lg text-[17px] leading-relaxed text-muted">
Upload, trim, and export video clips. Stream live when you need to,
and keep every recording in one place.
</p>
</div>
);
};
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link to="/create">
<Button size="lg" icon={<Upload size={16} />}>
Upload a clip
</Button>
</Link>
<Link to="/my-clips">
<Button size="lg" variant="secondary" icon={<LayoutGrid size={16} />}>
Browse library
</Button>
</Link>
</div>
</section>
{/* Feature grid */}
<section className="mt-20 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{features.map(({ icon: Icon, title, description }) => (
<article
key={title}
className="group rounded-2xl border border-line bg-surface p-5 transition-all duration-200 ease-fluid hover:-translate-y-0.5 hover:border-line-strong hover:shadow-raised"
>
<div className="mb-4 flex h-9 w-9 items-center justify-center rounded-[10px] bg-accent/12 text-accent transition-colors group-hover:bg-accent group-hover:text-accent-fg">
<Icon size={17} />
</div>
<h2 className="text-sm font-semibold text-fg">{title}</h2>
<p className="mt-1.5 text-[13.5px] leading-relaxed text-muted">{description}</p>
</article>
))}
</section>
{/* Footer strip */}
<section className="mt-14 flex flex-col items-center gap-4 rounded-2xl border border-line bg-panel px-6 py-8 text-center">
<p className="font-serif text-xl text-fg">Ready when you are.</p>
<p className="max-w-md text-sm text-muted">
Supports MP4, MOV, WebM, and OGG. Sign in with Google to save clips to your library.
</p>
<Link to="/create">
<Button variant="secondary" icon={<ArrowRight size={15} />}>
Start a clip
</Button>
</Link>
</section>
</div>
);
export default Home;

View File

@@ -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<MediaSummary[]>;
remove: (id: number) => Promise<unknown>;
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<MediaSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto w-full max-w-6xl px-6 pb-16 pt-10 sm:px-8">
<PageHeader
title={title}
description={description}
action={
items.length > 0 ? (
<div className="relative w-full sm:w-64">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-subtle"
/>
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${noun}s`}
className="pl-9"
/>
</div>
) : undefined
}
/>
{error && (
<div className="mt-6 flex items-start gap-2.5 rounded-xl border border-danger/25 bg-danger/8 px-4 py-3 text-sm text-danger">
<TriangleAlert size={16} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="mt-8">
{loading ? (
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2.5">
<Skeleton className="aspect-video w-full rounded-xl" />
<Skeleton className="h-3.5 w-4/5" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : items.length === 0 ? (
<EmptyState icon={emptyIcon} title={emptyTitle} description={emptyDescription} />
) : visible.length === 0 ? (
<EmptyState
icon={<Search size={20} />}
title="No matches"
description={`Nothing here matches “${query}”.`}
/>
) : (
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{visible.map((item) => (
<VideoCard
key={item.id}
id={item.id}
title={item.title}
duration={item.duration}
createdAt={item.createdAt}
mediaApiPath={mediaApiPath}
playerPath={playerPath}
itemLabel={noun}
onEdit={() => navigate(editPath(item.id))}
onDelete={() => handleDelete(item.id)}
/>
))}
</div>
)}
</div>
</div>
);
};
export default MediaLibrary;

View File

@@ -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<MediaItem | null>,
};
const ControlButton = ({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) => (
<button
aria-label={label}
title={label}
onClick={onClick}
className="flex items-center justify-center rounded-lg p-1.5 text-white/90 transition-colors duration-150 hover:bg-white/15 hover:text-white"
>
{children}
</button>
);
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 (
<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-gray-100 flex items-center justify-center">
<Lock size={24} className="text-gray-400" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
<ArrowLeft size={16} />
Go back
</button>
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<EmptyState
icon={<Lock size={22} />}
title="Not authenticated"
description={`You don't have access to this ${noun.toLowerCase()}.`}
action={
<Button variant="secondary" icon={<ArrowLeft size={15} />} onClick={() => navigate(-1)}>
Go back
</Button>
}
/>
</div>
);
}
return (
<div className="h-full flex flex-col p-4 gap-3">
<button
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 px-4 py-5 sm:px-6">
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft size={15} />}
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
className="-ml-3 self-start"
>
<ArrowLeft size={16} />
Back
</button>
</Button>
{/* Player */}
<div
ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
className="relative min-h-0 flex-1 select-none overflow-hidden rounded-2xl border border-line bg-black shadow-raised"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
@@ -241,7 +265,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
>
<video
ref={videoRef}
className="w-full h-full object-contain"
className="h-full w-full object-contain"
src={mediaUrl}
// Send auth cookies with the cross-origin Range requests.
crossOrigin="use-credentials"
@@ -264,85 +288,118 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
{/* Buffering spinner */}
{isWaiting && mediaUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Spinner size={40} className="text-white" />
</div>
)}
{/* Big centre play badge while paused */}
{!isPlaying && mediaLoaded && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
<Play size={26} fill="currentColor" className="ml-1" />
</span>
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
<div
className={clsx(
"pointer-events-none absolute inset-0 flex flex-col justify-end transition-opacity duration-300",
controlsVisible ? "opacity-100" : "opacity-0",
)}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
{/* Controls bar — pointer-events-auto only here */}
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
className="pointer-events-auto relative px-4 pb-4 pt-10"
onClick={(e) => e.stopPropagation()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
className="group/prog relative mb-3 h-1 cursor-pointer rounded-full bg-white/25 transition-[height] duration-150 hover:h-1.5"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div className="absolute inset-y-0 left-0 rounded-full bg-white/35" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 rounded-full bg-accent" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
className="absolute top-1/2 h-3.5 w-3.5 -translate-y-1/2 rounded-full bg-accent shadow opacity-0 transition-opacity group-hover/prog:opacity-100"
style={{ left: `calc(${progressPct}% - 7px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
<div className="flex items-center gap-1 text-white">
<ControlButton label={isPlaying ? "Pause" : "Play"} onClick={togglePlay}>
{isPlaying ? <Pause size={19} fill="currentColor" /> : <Play size={19} fill="currentColor" />}
</ControlButton>
<ControlButton label="Back 5 seconds" onClick={() => skip(-5)}>
<RotateCcw size={17} />
</ControlButton>
<ControlButton label="Forward 5 seconds" onClick={() => skip(5)}>
<RotateCw size={17} />
</ControlButton>
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<div className="group/vol ml-1 flex items-center gap-1.5">
<ControlButton label={isMuted ? "Unmute" : "Mute"} onClick={toggleMute}>
<VolumeIcon size={17} />
</ControlButton>
<div className="w-0 overflow-hidden transition-[width] duration-200 group-hover/vol:w-20">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
aria-label="Volume"
className="slider slider-onvideo w-20"
/>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-white/80">
<span className="ml-2 font-mono text-[12px] tabular-nums text-white/85">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
<ControlButton
label={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
</ControlButton>
</div>
</div>
</div>
</div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!mediaLoaded && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
{error && (
<p className="shrink-0 rounded-xl border border-danger/25 bg-danger/8 px-4 py-2.5 text-[13px] text-danger">
{error}
</p>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box>
{!mediaLoaded && !error && (
<p className="flex shrink-0 items-center gap-2 text-[13px] text-muted">
<Spinner size={13} />
Loading {noun.toLowerCase()}…
</p>
)}
<Card className="shrink-0 p-5">
<h1 className="font-serif text-xl leading-tight text-fg">{item?.title || "Untitled"}</h1>
<p className="mt-1 text-[13px] text-subtle">{timeAgo}</p>
{item?.description && (
<p className="mt-3 whitespace-pre-line border-t border-line pt-3 text-sm leading-relaxed text-muted">
{item.description}
</p>
)}
</Card>
</div>
);
};

View File

@@ -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<Clip[]>([]);
const [error, setError] = useState<null | string>(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 (
<div className="p-6">
<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">
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
))}
</div>
{clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
</div>
);
}
export default MyClips;

View File

@@ -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<Vod[]>([]);
const [error, setError] = useState<null | string>(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 (
<div className="p-6">
<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">
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
mediaApiPath="/api/v1/vods"
playerPath="/vod"
itemLabel="vod"
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)}
/>
))}
</div>
{vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
</div>
);
};
export default MyVods;

View File

@@ -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 = () => (
<div className="mx-auto flex w-full max-w-lg flex-col items-center px-6 pb-16 pt-24 text-center">
<p className="font-mono text-[13px] tracking-[0.2em] text-subtle">404</p>
<h1 className="mt-3 font-serif text-3xl leading-tight text-fg">Nothing here.</h1>
<p className="mt-3 text-sm leading-relaxed text-muted">
That page doesn't exist — it may have been renamed, or the clip was deleted.
</p>
<Link to="/" className="mt-7">
<Button variant="secondary" icon={<HomeIcon size={15} />}>Back home</Button>
</Link>
</div>
);
export default NotFound;

View File

@@ -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<User | null>(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 (
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-8 h-32 w-full rounded-2xl" />
<Skeleton className="mt-4 h-48 w-full rounded-2xl" />
</div>
);
}
if (!user) {
return (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to view your profile.
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<EmptyState
icon={<UserCircle2 size={22} />}
title="You're signed out"
description="Sign in with Google from the top bar to see your profile and stream key."
/>
</div>
);
}
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<PageHeader title="Profile" description="Your account and streaming credentials." />
<Box className="p-6 mb-4 flex items-center gap-5">
<img
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt="Profile picture"
className="w-20 h-20 rounded-full shadow-sm"
/>
<div>
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400 mt-1">
Member since {formatLocalDate(user.createdAt)}
</p>
<Card className="mt-8 overflow-hidden">
{/* Warm banner echoing the brand palette */}
<div className="h-20 bg-gradient-to-r from-accent/25 via-kraft/25 to-manilla/30" />
<div className="flex flex-col gap-4 px-6 pb-6 sm:flex-row sm:items-end">
<img
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt=""
className="-mt-10 h-20 w-20 rounded-full border-4 border-surface bg-surface object-cover shadow-raised"
/>
<div className="min-w-0 flex-1 sm:pb-1">
<p className="font-serif text-xl leading-tight text-fg">{user.name}</p>
<p className="text-sm text-muted">@{user.username}</p>
</div>
</div>
</Box>
<Box className="p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming.
Keep it private — anyone with this key can stream to your account.
</p>
<dl className="grid grid-cols-1 gap-px border-t border-line bg-line sm:grid-cols-2">
<div className="bg-surface px-6 py-4">
<dt className="text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">Email</dt>
<dd className="mt-1 truncate text-sm text-fg">{user.email}</dd>
</div>
<div className="bg-surface px-6 py-4">
<dt className="text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">Member since</dt>
<dd className="mt-1 text-sm text-fg">{formatLocalDate(user.createdAt)}</dd>
</div>
</dl>
</Card>
<div className="flex items-center gap-2">
<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)}
<Card className="mt-4 p-6">
<CardHeader
icon={<KeyRound size={16} />}
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."
/>
<div className="mt-5 flex items-center gap-2">
<div className="min-w-0 flex-1 overflow-hidden rounded-[10px] border border-line bg-sunken px-3.5 py-2.5 font-mono text-[13px] tracking-tight text-fg">
<span className="block truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 40))}
</span>
</div>
<button
onClick={() => 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"
<IconButton
label={keyVisible ? "Hide stream key" : "Show stream key"}
onClick={() => setKeyVisible((v) => !v)}
className="border border-line bg-surface"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</IconButton>
<button
<IconButton
label="Copy stream key"
onClick={handleCopy}
title="Copy key"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
className={clsx("border border-line bg-surface", copied && "text-success")}
>
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button>
{copied ? <Check size={16} /> : <Copy size={16} />}
</IconButton>
</div>
</Box>
</Card>
<Card className="mt-4 p-6">
<CardHeader
icon={<Monitor size={16} />}
title="Appearance"
description="Choose how the interface looks on this device."
/>
<div className="mt-5 grid grid-cols-2 gap-3">
{themeOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => 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",
)}
>
<Icon size={17} className={theme === value ? "text-accent" : "text-subtle"} />
<span className="text-sm font-medium">{label}</span>
{theme === value && <Check size={15} className="ml-auto text-accent" />}
</button>
))}
</div>
</Card>
</div>
);
};

View File

@@ -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" }) => (
<div className="flex items-center gap-2.5">
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
</div>
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => {
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
<div className="flex items-start gap-2.5 text-[13px]">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-danger/15 text-danger">
<X size={11} />
</span>
<p className="text-danger">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-gray-700">{job.label} saved</p>
<div className="flex items-center gap-2.5 text-[13px]">
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-success/15 text-success">
<Check size={11} />
</span>
<p className="text-fg">{job.label} <span className="text-muted">saved</span></p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
<div className="flex items-center gap-2.5">
<Spinner size={14} className="text-accent" />
<div className="min-w-0 flex-1">
<p className="mb-1.5 text-[13px] text-muted">{job.label}…</p>
<Progress value={job.progress} indeterminate={job.progress <= 0} />
</div>
</div>
);
@@ -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 (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to control your stream.
<div className="mx-auto w-full max-w-2xl px-6 pb-16 pt-10 sm:px-8">
<EmptyState
icon={<Radio size={22} />}
title="You're signed out"
description="Sign in with Google from the top bar to control your stream and save clips."
/>
</div>
);
}
@@ -240,156 +247,223 @@ const StreamControl = () => {
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-6 pb-16 pt-10 sm:px-8">
<PageHeader title="Stream" description="Mark moments as they happen, then save them as clips." />
{/* Hero status bar */}
<Box className={clsx(
"p-6 flex items-center justify-between gap-6 border-l-4",
isStreaming ? "border-l-red-500" : "border-l-gray-200"
)}>
<div className="flex items-center gap-3.5">
<span className="relative flex h-3.5 w-3.5">
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
</span>
{/* Status hero */}
<Card
className={clsx(
"mt-4 flex flex-wrap items-center justify-between gap-6 overflow-hidden p-6",
isStreaming && "ring-1 ring-live/25",
)}
>
<div className="flex items-center gap-4">
<LiveDot active={isStreaming} size={12} />
<div>
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
<p className="font-serif text-2xl leading-tight text-fg">
{isStreaming ? "Live" : "Offline"}
</p>
{isStreaming && streamDetails?.startDate ? (
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
<p className="mt-0.5 text-[13px] text-muted">
Started {formatLocalDate(streamDetails.startDate, {
hour: "2-digit",
minute: "2-digit",
})}
</p>
) : !isStreaming ? (
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
<p className="mt-0.5 max-w-sm text-[13px] text-muted">
Point OBS at your stream key — it's on your profile — to go live.
</p>
) : null}
</div>
</div>
{isStreaming && elapsed ? (
<div className="text-right">
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
<p className="font-mono text-3xl font-medium tabular-nums text-fg">{elapsed}</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle">elapsed</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
<Radio size={30} className="text-line-strong" />
)}
</Box>
</Card>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-accent flex-shrink-0" />
<input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
className={inputClass}
/>
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
Add Marker
</BlueButton>
{/* Marker composer */}
<Card className="flex flex-col gap-4 p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Bookmark size={17} className="shrink-0 text-accent" />
<Input
type="text"
value={markerMessage}
onChange={(e) => setMarkerMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
placeholder="What just happened?"
/>
</div>
<Button
onClick={handleAddMarker}
loading={markerBusy}
disabled={!markerMessage.trim()}
>
Add marker
</Button>
</div>
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markerError && <p className="text-[13px] text-danger">{markerError}</p>}
{markers.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 pt-1.5">
{markers.map((m) => {
const isStart = startMarkerId === String(m.id);
const isEnd = endMarkerId === String(m.id);
return (
<div key={m.id} className="group relative flex-shrink-0">
<div key={m.id} className="group relative shrink-0">
<button
onClick={() => handleChipClick(m.id)}
className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
"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",
)}
>
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
<span className="text-[11px] text-gray-400">
<span className="max-w-44 truncate text-[12.5px] font-medium text-fg">
{m.message}
</span>
<span className="font-mono text-[11px] tabular-nums text-subtle">
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
{isStart && <span className="text-primary font-medium"> · start</span>}
{isEnd && <span className="text-accent font-medium"> · end</span>}
{isStart && <span className="font-sans font-medium text-accent-ink"> · start</span>}
{isEnd && <span className="font-sans font-medium text-kraft"> · end</span>}
</span>
</button>
<button
onClick={(e) => { 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"
>
<X size={10} />
<X size={11} />
</button>
</div>
);
})}
</div>
)}
</Box>
</Card>
{/* Forefront actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
<Box className="p-6 flex flex-col gap-3">
<CardHeader icon={Scissors} title="Clip the last few seconds" />
<div className="flex flex-col gap-1.5">
<label className={labelClass}>Duration (seconds)</label>
<input
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
className={inputClass}
/>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Box>
{/* Save actions */}
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-2">
<Card className="flex flex-col gap-4 p-5">
<CardHeader
icon={<Scissors size={16} />}
title="Clip the last few seconds"
description="Grabs the tail end of the live buffer."
/>
<Field label="Duration (seconds)">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={clipDuration}
onChange={(e) => setClipDuration(Number(e.target.value))}
/>
)}
</Field>
<Input
type="text"
placeholder="Title"
value={clipTitle}
onChange={(e) => setClipTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
rows={2}
value={clipDescription}
onChange={(e) => setClipDescription(e.target.value)}
/>
{clipError && <p className="text-[13px] text-danger">{clipError}</p>}
<Button onClick={handleClip} className="mt-auto self-start">Clip it</Button>
</Card>
<Card className="flex flex-col gap-4 p-5">
<CardHeader
icon={<Film size={16} />}
title="Save between two markers"
description="Tap markers above to set the in and out points."
/>
<Box className="p-6 flex flex-col gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
{markers.length < 2 ? (
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
<p className="text-[13px] text-muted">
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 flex-wrap items-center gap-2">
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
"max-w-full truncate rounded-full border px-3 py-1 text-[12px] font-medium",
startMarker
? "border-accent/40 bg-accent/8 text-accent-ink"
: "border-dashed border-line-strong text-subtle",
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
{startMarker ? startMarker.message : "Set a start"}
</span>
<span className="text-gray-300">→</span>
<span className="text-subtle">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
"max-w-full truncate rounded-full border px-3 py-1 text-[12px] font-medium",
endMarker
? "border-kraft/50 bg-kraft/12 text-kraft"
: "border-dashed border-line-strong text-subtle",
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
{endMarker ? endMarker.message : "Set an end"}
</span>
</div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
<Input
type="text"
placeholder="Title"
value={markerSaveTitle}
onChange={(e) => setMarkerSaveTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
rows={2}
value={markerSaveDescription}
onChange={(e) => setMarkerSaveDescription(e.target.value)}
/>
{markerSaveError && <p className="text-[13px] text-danger">{markerSaveError}</p>}
</>
)}
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
</Box>
<Button
onClick={handleSaveByMarkers}
disabled={markers.length < 2}
className="mt-auto self-start"
>
Save section
</Button>
</Card>
</div>
<Box className="p-5 flex flex-col gap-3">
<CardHeader icon={Activity} title="Recent actions" />
<Card className="flex flex-col gap-4 p-5">
<CardHeader icon={<Activity size={16} />} title="Recent actions" />
{jobs.length === 0 ? (
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
<p className="text-[13px] text-muted">Clip and save actions will show up here.</p>
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3.5">
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
</div>
)}
</Box>
</Card>
</>
)}
</div>

View File

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