restyle the rest of the pages
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
38
frontend/src/components/GoogleSignIn.tsx
Normal file
38
frontend/src/components/GoogleSignIn.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
57
frontend/src/components/JobProgress.tsx
Normal file
57
frontend/src/components/JobProgress.tsx
Normal 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 };
|
||||
24
frontend/src/components/PageHeader.tsx
Normal file
24
frontend/src/components/PageHeader.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
32
frontend/src/components/buttons/Button.tsx
Normal file
32
frontend/src/components/buttons/Button.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
283
frontend/src/components/video/TrimTimeline.tsx
Normal file
283
frontend/src/components/video/TrimTimeline.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user