feat(style): update fonts & themes in frontend (#14)
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Successful in 4m17s
CI / Deploy to server (push) Successful in 1m9s

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-08-16 16:17:41 +01:00
parent 29bc60d417
commit 3db16cd380
34 changed files with 356 additions and 1303 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,13 +58,13 @@ const Topbar = ({
} }
return ( return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}> <div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-sidebar border-b border-hairline")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}> <MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>} {sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton> </MenuButton>
{ user ? ( { user ? (
<div> <div className={"hover:bg-hover rounded-lg p-0.5"}>
<img <img
className={"w-8 h-8 rounded-full inline-block"} className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl} src={user.profilePictureUrl}
@@ -76,8 +76,8 @@ const Topbar = ({
{user.name} {user.name}
{isStreaming && ( {isStreaming && (
<span className="relative flex h-2 w-2"> <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="animate-ping absolute inline-flex h-full w-full rounded-full bg-live opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-live" />
</span> </span>
)} )}
</span> </span>
@@ -86,7 +86,7 @@ const Topbar = ({
onClick={() => navigate("/profile")} /> onClick={() => navigate("/profile")} />
<DropdownItem item="Logout" <DropdownItem item="Logout"
onClick={() => handleLogout()} onClick={() => handleLogout()}
className={"text-red-500 font-medium"} /> className={"text-error font-medium"} />
</Dropdown> </Dropdown>
</div> </div>
) : ) :

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none"> <span className="absolute top-1.5 left-1.5 bg-text-primary/75 text-on-dark px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
@@ -82,18 +82,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
> >
<button <button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })} onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors" className="bg-text-primary/75 hover:bg-text-primary/90 text-on-dark rounded p-0.5 transition-colors"
> >
<MoreVertical size={14} /> <MoreVertical size={14} />
</button> </button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden"> <div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<> {!confirmDelete && (<>
{onEdit && ( {onEdit && (
<button <button
onClick={(e) => stopAndRun(e, onEdit)} onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
> >
<Pencil size={13} /> Edit <Pencil size={13} /> Edit
</button> </button>
@@ -101,7 +101,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && ( {onDelete && (
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50" className="flex items-center gap-2 w-full px-3 py-2 text-sm text-error hover:bg-hover"
> >
<Trash2 size={13} /> Delete <Trash2 size={13} /> Delete
</button> </button>
@@ -109,17 +109,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p> <p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors" className="flex-1 text-xs bg-error text-on-accent rounded px-2 py-1 hover:bg-terracotta-hover transition-colors"
> >
Delete Delete
</button> </button>
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors" className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
> >
Cancel Cancel
</button> </button>
@@ -133,10 +133,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</div> </div>
<div className="flex flex-col p-2"> <div className="flex flex-col p-2">
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2"> <p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
{title === "" ? "(No Title)" : title} {title === "" ? "(No Title)" : title}
</p> </p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p> <p className="text-muted text-xs mt-0.5">{timeAgo}</p>
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -1,37 +1,50 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme { @theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; /* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Breakpoints */ /* ── Surfaces ────────────────────────────────────────────────────────── */
--breakpoint-3xl: 1920px; --color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-dropzone: #F9F6EF;
--color-primary: oklch(0.55 0.21 254); /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-primary-pressed: oklch(0.45 0.21 254); --color-text-primary: #2B2620;
--color-secondary: oklch(0.94 0.01 250); --color-text-strong: #35302A;
--color-accent: oklch(0.68 0.20 288); --color-text-body: #554D44;
--color-text: oklch(0.17 0.01 270); --color-text-secondary: #6E6559;
--color-background: oklch(0.97 0.005 250); --color-muted: #9A9084;
--color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* Easing */ /* ── Accents ─────────────────────────────────────────────────────────── */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --color-terracotta: #A85A38;
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); --color-terracotta-hover: #94492A;
} --color-terracotta-light: #C87A52;
--color-terracotta-disabled: #C9AE9C;
--color-olive: #6E7A50;
#range-slider .range-slider__range { /* ── Status ──────────────────────────────────────────────────────────── */
background: var(--color-primary); --color-live: #B5432E;
} --color-error: #B5432E;
#range-slider .range-slider__thumb { /* ── Ink on dark / accent surfaces ───────────────────────────────────── */
background: var(--color-primary); --color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #F0EBE2;
--color-hover: #E7E0D2;
--color-selected: #F6EFE6;
--color-ring: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
} }
:root { :root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
@@ -42,4 +55,35 @@
body { body {
background-color: var(--color-background); background-color: var(--color-background);
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--color-text-body);
}
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
color: var(--color-text-primary);
}
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
defaults; the #id beats those class selectors regardless of import order. */
#range-slider {
height: 8px;
background: var(--color-fields);
border-radius: 8px;
}
#range-slider .range-slider__range {
background: var(--color-terracotta-light);
}
#range-slider .range-slider__thumb {
width: 18px;
height: 18px;
background: var(--color-terracotta);
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
}
#range-slider .range-slider__thumb:focus-visible {
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-terracotta) 35%, transparent);
} }

View File

@@ -34,7 +34,7 @@ const MainLayout = () => {
}, [user]); }, [user]);
return ( 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={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
user={user} user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>

View File

@@ -79,7 +79,7 @@ const ClipEdit = () => {
const interval = setInterval(async () => await pollProgress(jobId, interval), 500); const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
}; };
const pollProgress = async (jobId: string, intervalId: number) => { const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
getJob(jobId) getJob(jobId)
.then((job) => { .then((job) => {
setProgress(job.progress); setProgress(job.progress);
@@ -128,7 +128,7 @@ const ClipEdit = () => {
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"} className={"w-full rounded-lg shadow-sm bg-text-primary"}
onLoadedMetadata={handleVideoMetadataLoaded} onLoadedMetadata={handleVideoMetadataLoaded}
/> />
@@ -142,7 +142,7 @@ const ClipEdit = () => {
<Playbar <Playbar
video={videoRef.current} video={videoRef.current}
videoMetadata={metadata} videoMetadata={metadata}
className={"w-full accent-primary"} className={"w-full accent-terracotta"}
/> />
<PlaybackSlider <PlaybackSlider
@@ -150,7 +150,7 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
sliderValue={playbackValue} sliderValue={playbackValue}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
className={"w-full accent-primary"} className={"w-full accent-terracotta"}
/> />
<ClipRangeSlider <ClipRangeSlider
@@ -158,11 +158,11 @@ const ClipEdit = () => {
videoMetadata={metadata} videoMetadata={metadata}
setSliderValue={setPlaybackValue} setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata} setMetadata={setOutputMetadata}
className={"w-full mt-2 bg-primary"} className={"w-full mt-2"}
/> />
{error && ( {error && (
<div className={"text-red-600 text-center mt-2"}> <div className={"text-error text-center mt-2"}>
{error} {error}
</div> </div>
)} )}

View File

@@ -1,12 +1,11 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
const press = () => { const press = () => {
if (!file) { if (!file) {
@@ -17,10 +16,17 @@ const ClipUpload = () => {
}; };
return ( return (
<div className="flex items-start justify-center p-8 min-h-full"> <div className="flex justify-center p-8">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md"> <Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1> <div
className="cursor-pointer rounded-lg border-2
border-dashed border-hairline bg-dropzone
p-4 text-center text-sm text-text-secondary
hover:bg-hover transition-colors duration-150
min-h-50 flex flex-col items-center justify-center gap-2"
>
<label className="cursor-pointer block">
Choose file
<input <input
type="file" type="file"
onChange={(e) => { onChange={(e) => {
@@ -28,12 +34,22 @@ const ClipUpload = () => {
setFile(selected); setFile(selected);
setError(null); 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" className="hidden"
/> />
</label>
{file && (
<p className="mt-2 text-xs text-text-secondary">{file.name}</p>
)}
</div>
<BlueButton onClick={press}>Upload</BlueButton> <button
onClick={press}
className={"hover:bg-hover p-1 rounded-md"}
>
Upload
</button>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-sm font-body text-center">{error}</p>}
</Box> </Box>
</div> </div>
) )

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ const MyClips = () => {
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
@@ -43,13 +43,13 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No clips yet.</p> <p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="text-sm mt-1">Upload your first clip to get started.</p>
</div> </div>
)} )}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>} {error && <p className="text-sm text-error mt-4">{error.toString()}</p>}
</div> </div>
); );
} }

View File

@@ -26,7 +26,7 @@ const MyVods = () => {
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => ( {vods.map((vod) => (
@@ -46,13 +46,13 @@ const MyVods = () => {
</div> </div>
{vods.length === 0 && !error && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No VoDs yet.</p> <p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="text-sm mt-1">Your stream recordings will appear here.</p>
</div> </div>
)} )}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>} {error && <p className="text-sm text-error mt-4">{error}</p>}
</div> </div>
); );
}; };

View File

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

View File

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

View File

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