restyle the rest of the pages
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped

This commit is contained in:
2026-08-02 16:32:00 +01:00
parent 11809a7da8
commit eab3f8be20
46 changed files with 2022 additions and 857 deletions

View File

@@ -6,7 +6,7 @@ type Props = {
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-md rounded-lg", className)}>
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
{children}
</div>
);

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
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)}
className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
onClick={() => onClick(item)}
>
{item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<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"
className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields 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">
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{children}
</ul>
)}

View File

@@ -0,0 +1,38 @@
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
import { useAuth } from "../utils/AuthContext.tsx";
import { getUser, login } from "../utils/api/users.ts";
type Props = {
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
shape?: "rectangular" | "pill";
size?: "small" | "medium" | "large";
width?: string;
};
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
const { setUser } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => getUser().then(setUser))
.catch((error) => console.error("Login failed:", error));
};
return (
<GoogleLogin
shape={shape}
size={size}
width={width}
useOneTap={false}
onSuccess={handleLogin}
/>
);
};
export default GoogleSignIn;

View File

@@ -2,7 +2,7 @@ const IsLivePulse = () => {
return (
<span className={"relative flex h-2 w-2"}>
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-live-red"
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
></span>

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import type { JobResponse } from "../utils/types.ts";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
// queued → reading source → encoding → writing file → complete
const stageOf = (job: TrackedJob): string => {
if (job.state === 'FAILED') return "failed";
if (job.state === 'SUCCEEDED') return "complete";
if (job.state === 'READY') return "queued";
if (job.progress < 0.05) return "reading source";
if (job.progress < 0.95) return "encoding";
return "writing file";
};
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
const failed = job.state === 'FAILED';
const done = job.state === 'SUCCEEDED';
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-[width] duration-500 ease-out",
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
)}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
{stageOf(job)}
</p>
<p className="font-data text-[11px] text-faint-mono">
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
</p>
</div>
</div>
);
};
export default JobProgress;
export { isSettled };
export type { TrackedJob };

View File

@@ -0,0 +1,24 @@
import type React from "react";
import clsx from "clsx";
import { kickerClass } from "../utils/styles.ts";
type Props = {
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
kicker: string;
title: string;
/** Optional trailing action, right-aligned against the title. */
children?: React.ReactNode;
className?: string;
};
const PageHeader = ({ kicker, title, children, className }: Props) => (
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
<div className="flex flex-col gap-1">
<p className={kickerClass}>{kicker}</p>
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
</div>
{children}
</header>
);
export default PageHeader;

View File

@@ -5,11 +5,11 @@ type Props = {
label: string;
};
// Label on the left, control on the right — the shape used for export settings.
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 className="flex items-center justify-between gap-4">
<label className="font-body text-[13px] text-text-body">{label}</label>
<div className="w-32 flex-shrink-0">{children}</div>
</div>
);

View File

@@ -2,8 +2,8 @@ import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import {useAuth} from "../utils/AuthContext.tsx";
import {type CredentialResponse, GoogleLogin} from "@react-oauth/google";
import {getUser, login, logout} from "../utils/api/users.ts";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
type props = {
className?: string
@@ -14,21 +14,6 @@ const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({ className }: props) => {
const { user, setUser } = useAuth()
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
getUser().then(userData => setUser(userData))
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() =>
@@ -96,13 +81,7 @@ const Sidebar = ({ className }: props) => {
) : (
<div>
<Divider />
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={
(credentialResponse) => handleLogin(credentialResponse
)}
/>
<GoogleSignIn />
</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

@@ -0,0 +1,32 @@
import React from "react";
import clsx from "clsx";
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
};
// Hover shifts one step darker within the same family — no transforms.
const variants: Record<Variant, string> = {
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
secondary: "bg-panel text-text-control hover:bg-chip-hover",
ghost: "text-muted hover:text-text-strong",
};
const Button = ({ variant = "primary", className, ...props }: Props) => (
<button
className={clsx(
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
"disabled:cursor-not-allowed",
variants[variant],
className
)}
{...props}
/>
);
export default Button;
export type { Variant };

View File

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

View File

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

View File

@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = {
metadata: VideoMetadata;
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";
// Mirrors CompressionService.buildBitrate so the number shown is the number used:
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
const BUDGET_MULTIPLIER = 0.9;
const AUDIO_RATIO = 0.15;
const MAX_AUDIO_BITRATE = 128;
export default function ConfigBox({ setMetadata, className }: Props) {
type Estimate = { total: number; audio: number; video: number; bpp: number | null };
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
const { fileSize, duration, width, height, fps } = metadata;
if (!fileSize || !duration || duration <= 0) return null;
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
const video = total - audio;
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
return { total, audio, video, bpp };
};
const formatBitrate = (kbps: number): string =>
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
return { word: "very soft", tone: "text-live-red" };
};
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
};
const sizeMb = Math.round(metadata.fileSize / 1000);
const estimate = estimateBitrate(metadata);
const verdict = estimate && verdictOf(estimate);
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>
<div className={clsx("flex flex-col gap-4", className)}>
<p className={kickerClass}>Export settings</p>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
<select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
<Selector label="Frames per second">
<select id="fps" name="fps" value={String(metadata.fps)}
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select>
</Selector>
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
<Selector label="Size limit (MB)">
<input type="number" min="1" value={sizeMb}
onChange={updateFileSize} className={monoInputClass}
/>
</Selector>
{/* What that budget actually buys, at this length and resolution. */}
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Estimated bitrate</p>
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
</div>
{estimate ? (
<>
<p className="font-data text-[15px] text-text-primary tabular-nums">
{formatBitrate(Math.max(0, estimate.video))}
</p>
<p className="font-body text-[12px] text-muted">
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
across {metadata.duration.toFixed(1)}s
</p>
</>
) : (
<p className="font-body text-[12px] text-muted">
Pick a trim range to see what the size limit buys you.
</p>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import BlueButton from "../buttons/BlueButton.tsx";
import React from "react";
import Button from "../buttons/Button.tsx";
import { kickerClass } from "../../utils/styles.ts";
type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>;
@@ -9,43 +10,44 @@ type props = {
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>
);
// queued → reading source → encoding → writing file → complete
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
if (downloadable) return "complete";
if (!uploading) return "waiting";
if (progress < 0.05) return "reading source";
if (progress < 0.95) return "encoding";
return "writing file";
};
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
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="flex flex-col gap-4">
<p className={kickerClass}>Export</p>
<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>
)
}
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
{uploading ? "Compressing…" : "Compress and save"}
</Button>
<div className="flex flex-col gap-1.5">
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className="font-data text-[11px] text-muted">{stageOf(progress, uploading, downloadable)}</p>
<p className="font-data text-[11px] text-faint-mono">{downloadable ? "done" : `${pct}%`}</p>
</div>
</div>
{downloadable && (
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
Download the file
</Button>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -8,13 +9,14 @@ type Props = {
};
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>
<div className={clsx("flex flex-col gap-2", className)}>
<label className={labelClass} htmlFor="clip-title">Title</label>
<input
id="clip-title"
type="text"
placeholder="Enter title"
placeholder="Cat walks across the patch bay"
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={inputClass}
/>
</div>
);

View File

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

View File

@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}, [video]);
return (
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className="flex gap-1">
<Icon size={24} />
<div className={clsx("flex justify-between items-center mb-3", className)}>
<div className="flex items-center gap-2 text-text-control">
<Icon size={20} />
<input
type="range"
min={0}
max={100}
value={volume}
onChange={updateVolume}
className="w-20"
className="w-20 accent-accent"
/>
</div>
<button
onClick={togglePlay}
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
{isPlaying ? <Pause size={22} /> : <Play size={22} />}
</button>
<span>
<span className="font-data text-[13px] text-text-secondary tabular-nums">
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span>
</div>

View File

@@ -0,0 +1,283 @@
import { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Range = [number, number];
type Props = {
/** Full length of the source video, in seconds. */
duration: number;
/** The trim window, in seconds. */
range: Range;
onRangeChange: (range: Range) => void;
/** Playhead position, in seconds. */
currentTime: number;
onSeek: (seconds: number) => void;
className?: string;
};
/** Never zoom in past this, or the handles have nothing left to grab. */
const MIN_VIEW_SECONDS = 0.5;
/** The shortest trim the handles will allow between them. */
const MIN_GAP = 0.1;
const ZOOM_STEP = 1.6;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
/** A readable tick interval that yields roughly 6 labels across the view. */
const tickInterval = (span: number): number => {
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
return steps.find((step) => span / step <= 6) ?? 3600;
};
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
const [view, setView] = useState<Range>([0, duration]);
// Which thing the pointer is currently dragging, if any.
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const overviewRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef(0);
// A new source resets the view to the whole thing.
useEffect(() => setView([0, duration]), [duration]);
const [viewStart, viewEnd] = view;
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
const zoomFactor = duration / viewSpan;
/** Seconds → percentage across the zoomed track. */
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
/** Pointer x → seconds, within the zoomed track. */
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width === 0) return span[0];
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return span[0] + ratio * (span[1] - span[0]);
};
/** Re-centre the view on `focus`, keeping it inside the source. */
const applyZoom = useCallback((nextSpan: number, focus: number) => {
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
let start = focus - span * ratio;
start = clamp(start, 0, Math.max(0, duration - span));
setView([start, start + span]);
}, [duration, view]);
const zoomBy = (factor: number) => {
const focus = clamp(currentTime, viewStart, viewEnd);
applyZoom(viewSpan / factor, focus);
};
/** Zoom the view down to the trim range, with a little air on each side. */
const zoomToRange = () => {
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
const padded = Math.min(duration, span * 1.25);
const centre = (range[0] + range[1]) / 2;
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
setView([start, start + padded]);
};
// Wheel over the track zooms around the pointer rather than scrolling the page.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
event.preventDefault();
const focus = toSeconds(event.clientX, track, view);
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
};
track.addEventListener("wheel", onWheel, { passive: false });
return () => track.removeEventListener("wheel", onWheel);
}, [applyZoom, view, viewSpan]);
// The move handler reads through refs, so it always sees the current view and
// range without having to be torn down and rebuilt on every frame of a drag.
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
latest.current = { view, range, onRangeChange, onSeek, duration };
// Listeners are attached on mousedown rather than in an effect: a fast drag
// would otherwise lose its first frames waiting for a re-render.
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setDragging(kind);
const onMove = (move: MouseEvent) => {
const state = latest.current;
const span = state.view[1] - state.view[0];
if (kind === "window") {
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
setView([start, start + span]);
return;
}
const at = toSeconds(move.clientX, trackRef.current, state.view);
if (kind === "playhead") {
state.onSeek(at);
} else if (kind === "in") {
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
state.onSeek(at);
} else {
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
state.onSeek(at);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDragging(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
if (duration <= 0) return null;
const interval = tickInterval(viewSpan);
const firstTick = Math.ceil(viewStart / interval) * interval;
const ticks: number[] = [];
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
const windowLeft = (viewStart / duration) * 100;
const windowWidth = (viewSpan / duration) * 100;
return (
<div className={clsx("flex flex-col gap-3 select-none", className)}>
{/* Overview — the whole source, with the zoom window drawn on top. */}
<div
ref={overviewRef}
onMouseDown={(event) => {
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
// Grabbing inside the window drags it; clicking outside jumps it here.
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
setView([start, start + viewSpan]);
beginDrag("window", event);
}}
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
>
<div
className="absolute inset-y-0 bg-olive-light/30"
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 w-px bg-accent"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
/>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={viewSpan >= duration}
title="Zoom out"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
−
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={viewSpan <= MIN_VIEW_SECONDS}
title="Zoom in"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
+
</button>
<span className="font-data text-[11px] text-faint-mono tabular-nums">
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
{" · "}
{formatTime(viewStart)} – {formatTime(viewEnd)}
</span>
<div className="flex-1" />
<button
onClick={zoomToRange}
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
>
Fit to trim
</button>
<button
onClick={() => setView([0, duration])}
disabled={viewSpan >= duration}
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
>
Whole clip
</button>
</div>
{/* Zoomed track */}
<div
ref={trackRef}
onMouseDown={(event) => {
onSeek(toSeconds(event.clientX, trackRef.current, view));
beginDrag("playhead", event);
}}
className="relative h-14 rounded-chip bg-fields cursor-pointer"
>
{ticks.map((at) => (
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
<div className="absolute inset-y-0 w-px bg-inactive/70" />
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
{formatTime(at)}
</span>
</div>
))}
{/* Trim band */}
<div
className="absolute inset-y-0 bg-olive-light/35"
style={{
left: `${clamp(toPct(range[0]), 0, 100)}%`,
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
}}
/>
{/* Playhead */}
<div
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
/>
{/* In / out handles — only grabbable while inside the view */}
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
const pct = toPct(at);
if (pct < -2 || pct > 102) return null;
return (
<div
key={edge}
onMouseDown={(event) => beginDrag(edge, event)}
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
className={clsx(
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
dragging === edge && "z-10"
)}
style={{ left: `${pct}%` }}
>
<div className="h-full w-[3px] bg-olive rounded-full" />
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
</div>
);
})}
</div>
</div>
);
};
export default TrimTimeline;
export type { Range };

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts";
@@ -10,6 +10,11 @@ type VideoCardProps = {
title: string,
duration: number,
createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void,
onDelete?: () => void,
className?: string,
@@ -18,23 +23,36 @@ type VideoCardProps = {
itemLabel?: string,
}
const fallbackThumbnail = "../../../public/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)));
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
const placeholders: Record<string, React.CSSProperties> = {
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
};
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
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`;
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
const isVod = itemLabel === 'vod';
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
const meta = [
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
fileSize ? formatFileSize(fileSize) : null,
].filter(Boolean).join(" · ");
// The technical detail, in mono, for when it matters.
const facts = [
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
isVod && fps ? `${Math.round(fps)} fps` : null,
].filter(Boolean);
useEffect(() => {
isThumbnailAvailable(thumbnailUrl)
@@ -62,15 +80,28 @@ 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 hover:shadow-xl transition-transform duration-300">
<img
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className={clsx(
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
className
)}>
<div
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
>
{thumbnailAvailable && (
<img
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<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-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{slug}
</span>
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{formatTime(duration)}
</span>
@@ -82,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
>
<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"
className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
>
<MoreVertical size={14} />
</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">
<div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card 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"
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
>
<Pencil size={13} /> Edit
</button>
@@ -101,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{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"
className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
>
<Trash2 size={13} /> Delete
</button>
@@ -109,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
<p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
className="flex-1 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
>
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 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
>
Cancel
</button>
@@ -132,11 +163,28 @@ 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={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
<p className={clsx(
"line-clamp-2",
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
<p
className="font-body text-[12.5px] text-muted"
title={`${dateToTimeAgo(stringToDate(createdAt))} — ${formatLocalDate(createdAt)}`}
>
{meta}
</p>
{facts.length > 0 && (
<p className="font-data text-[10.5px] text-faint-mono truncate">
{facts.join(" · ")}
</p>
)}
</div>
</div>
</Link>