feat: overhaul frontend styling
This commit is contained in:
@@ -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>
|
||||
|
||||
670
frontend/package-lock.json
generated
670
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,46 +1,96 @@
|
||||
// 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}>
|
||||
<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={<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="/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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -1,51 +1,142 @@
|
||||
import clsx from "clsx";
|
||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
||||
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" />
|
||||
</nav>
|
||||
|
||||
{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) => (
|
||||
<>
|
||||
<Divider />
|
||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
26
frontend/src/components/ThemeProvider.tsx
Normal file
26
frontend/src/components/ThemeProvider.tsx
Normal 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;
|
||||
@@ -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>
|
||||
<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,
|
||||
)}
|
||||
</span>
|
||||
}>
|
||||
<DropdownItem item="Profile"
|
||||
onClick={() => navigate("/profile")} />
|
||||
<DropdownItem item="Logout"
|
||||
onClick={() => handleLogout()}
|
||||
className={"text-red-500 font-medium"} />
|
||||
</Dropdown>
|
||||
</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"}
|
||||
shape="pill"
|
||||
size="medium"
|
||||
text="signin"
|
||||
theme={theme === "dark" ? "filled_black" : "outline"}
|
||||
useOneTap={false}
|
||||
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
|
||||
)}
|
||||
onSuccess={onLogin}
|
||||
onError={() => console.error("Google login failed")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topbar;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
45
frontend/src/components/ui/Badge.tsx
Normal file
45
frontend/src/components/ui/Badge.tsx
Normal 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;
|
||||
63
frontend/src/components/ui/Button.tsx
Normal file
63
frontend/src/components/ui/Button.tsx
Normal 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;
|
||||
47
frontend/src/components/ui/Card.tsx
Normal file
47
frontend/src/components/ui/Card.tsx
Normal 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;
|
||||
31
frontend/src/components/ui/EmptyState.tsx
Normal file
31
frontend/src/components/ui/EmptyState.tsx
Normal 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;
|
||||
42
frontend/src/components/ui/Field.tsx
Normal file
42
frontend/src/components/ui/Field.tsx
Normal 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} />
|
||||
);
|
||||
27
frontend/src/components/ui/IconButton.tsx
Normal file
27
frontend/src/components/ui/IconButton.tsx
Normal 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;
|
||||
95
frontend/src/components/ui/Menu.tsx
Normal file
95
frontend/src/components/ui/Menu.tsx
Normal 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;
|
||||
23
frontend/src/components/ui/PageHeader.tsx
Normal file
23
frontend/src/components/ui/PageHeader.tsx
Normal 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;
|
||||
31
frontend/src/components/ui/Progress.tsx
Normal file
31
frontend/src/components/ui/Progress.tsx
Normal 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;
|
||||
7
frontend/src/components/ui/Skeleton.tsx
Normal file
7
frontend/src/components/ui/Skeleton.tsx
Normal 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;
|
||||
26
frontend/src/components/ui/Spinner.tsx
Normal file
26
frontend/src/components/ui/Spinner.tsx
Normal 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;
|
||||
31
frontend/src/components/ui/ThemeToggle.tsx
Normal file
31
frontend/src/components/ui/ThemeToggle.tsx
Normal 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;
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
156
frontend/src/components/video/ExportPanel.tsx
Normal file
156
frontend/src/components/video/ExportPanel.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/video/PreviewControls.tsx
Normal file
113
frontend/src/components/video/PreviewControls.tsx
Normal 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;
|
||||
68
frontend/src/components/video/TrimRange.tsx
Normal file
68
frontend/src/components/video/TrimRange.tsx
Normal 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;
|
||||
@@ -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,70 +79,84 @@ 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">
|
||||
<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 : fallbackThumbnail}
|
||||
alt="Video Thumbnail"
|
||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
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">
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
{(onEdit || onDelete) && (
|
||||
{hasMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
className="absolute right-2 top-2"
|
||||
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"
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
|
||||
{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="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 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
||||
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 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
||||
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>
|
||||
)}
|
||||
@@ -132,15 +164,14 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<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="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] text-subtle">{timeAgo}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default VideoCard;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
#range-slider .range-slider__range {
|
||||
background: var(--color-primary);
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb {
|
||||
background: var(--color-primary);
|
||||
@keyframes fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 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;
|
||||
@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(--color-background);
|
||||
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(--c-accent);
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb {
|
||||
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);
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb:hover,
|
||||
#range-slider .range-slider__thumb[data-active="true"] {
|
||||
transform: translate(-50%, -50%) scale(1.15);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}`}/>
|
||||
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
|
||||
className={"transition-all duration-300"}
|
||||
sidebarToggled={sidebarToggled}
|
||||
setSidebarToggled={setSidebarToggled}
|
||||
user={user}
|
||||
fetchUser={fetchUser}
|
||||
isStreaming={isStreaming}/>
|
||||
<div className="overflow-auto bg-background">
|
||||
<Outlet />
|
||||
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
25
frontend/src/lib/theme.ts
Normal 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";
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
let jobId: string;
|
||||
try {
|
||||
jobId = await compress(localFile, outputMetadata);
|
||||
setUploadedId(jobId);
|
||||
} catch (err: unknown) {
|
||||
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||
};
|
||||
|
||||
const pollProgress = async (jobId: string, intervalId: number) => {
|
||||
getJob(jobId)
|
||||
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'}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
if (job.state === "FAILED") {
|
||||
setError(`Compression failed: ${job.errorOutput ?? "Unknown error"}`);
|
||||
stopPolling();
|
||||
} else if (job.isComplete) {
|
||||
clearInterval(intervalId);
|
||||
setDownloadable(true);
|
||||
setUploading(false);
|
||||
stopPolling();
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
setError(`Failed to fetch progress: ${err.message}`);
|
||||
clearInterval(intervalId);
|
||||
setUploading(false);
|
||||
stopPolling();
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
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"}`);
|
||||
setExporting(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={"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-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>}
|
||||
/>
|
||||
|
||||
<Box className={"w-4/5 h-full m-auto"}>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
<ConfigBox setMetadata={setOutputMetadata} />
|
||||
</Box>
|
||||
|
||||
{metadata &&
|
||||
<Box className={"mt-4 p-5"}>
|
||||
<Playbar
|
||||
video={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
className={"w-full accent-primary"}
|
||||
/>
|
||||
|
||||
<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}
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<ExportPanel
|
||||
metadata={output}
|
||||
setMetadata={setOutput}
|
||||
onExport={handleExport}
|
||||
onDownload={handleDownload}
|
||||
downloadable={downloadable}
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
exporting={exporting}
|
||||
error={error}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,42 +1,138 @@
|
||||
import {useState} from "react";
|
||||
import { useRef, useState, type DragEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import Box from "../components/Box.tsx";
|
||||
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>
|
||||
|
||||
<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"
|
||||
<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."
|
||||
/>
|
||||
|
||||
<BlueButton onClick={press}>Upload</BlueButton>
|
||||
|
||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||
</Box>
|
||||
<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>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => accept(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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;
|
||||
|
||||
@@ -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;
|
||||
127
frontend/src/pages/EditMedia.tsx
Normal file
127
frontend/src/pages/EditMedia.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
const Home = () => (
|
||||
<div className="mx-auto w-full max-w-5xl px-6 pb-20 pt-14 sm:px-8">
|
||||
{/* 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.
|
||||
<section className="flex flex-col items-center text-center">
|
||||
<img src="/logo.png" alt="" className="mb-8 h-16 w-auto object-contain" />
|
||||
|
||||
<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>
|
||||
<p className="text-base text-gray-500 leading-relaxed">
|
||||
Upload, trim, and export video clips. Stream live if you need to.
|
||||
|
||||
<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 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 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
||||
<section className="mt-20 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map(({ icon: Icon, title, description }) => (
|
||||
<div
|
||||
<article
|
||||
key={title}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||
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="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 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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Format note */}
|
||||
<p className="text-xs text-gray-400">
|
||||
Supports MP4, MOV, WebM, and OGG.
|
||||
{/* 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;
|
||||
|
||||
161
frontend/src/pages/MediaLibrary.tsx
Normal file
161
frontend/src/pages/MediaLibrary.tsx
Normal 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;
|
||||
@@ -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} />
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
18
frontend/src/pages/NotFound.tsx
Normal file
18
frontend/src/pages/NotFound.tsx
Normal 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;
|
||||
@@ -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">
|
||||
<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="Profile picture"
|
||||
className="w-20 h-20 rounded-full shadow-sm"
|
||||
alt=""
|
||||
className="-mt-10 h-20 w-20 rounded-full border-4 border-surface bg-surface object-cover shadow-raised"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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)}
|
||||
</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"
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
{/* 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?"
|
||||
className={inputClass}
|
||||
/>
|
||||
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||
Add Marker
|
||||
</BlueButton>
|
||||
</div>
|
||||
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
||||
<Button
|
||||
onClick={handleAddMarker}
|
||||
loading={markerBusy}
|
||||
disabled={!markerMessage.trim()}
|
||||
>
|
||||
Add marker
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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
|
||||
{/* 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))}
|
||||
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>
|
||||
)}
|
||||
</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>}
|
||||
</>
|
||||
)}
|
||||
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box className="p-5 flex flex-col gap-3">
|
||||
<CardHeader icon={Activity} title="Recent actions" />
|
||||
<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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSaveByMarkers}
|
||||
disabled={markers.length < 2}
|
||||
className="mt-auto self-start"
|
||||
>
|
||||
Save section
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user