From eab3f8be208846a0e4fe775bf5f2634d38c8552b Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Sun, 2 Aug 2026 16:32:00 +0100 Subject: [PATCH] restyle the rest of the pages --- .env.example | 3 +- frontend/package-lock.json | 35 - frontend/package.json | 1 - frontend/src/components/Card.tsx | 2 +- frontend/src/components/Dropdown.tsx | 6 +- frontend/src/components/GoogleSignIn.tsx | 38 + frontend/src/components/IsLivePulse.tsx | 2 +- frontend/src/components/JobProgress.tsx | 57 ++ frontend/src/components/PageHeader.tsx | 24 + frontend/src/components/Selector.tsx | 8 +- frontend/src/components/Sidebar.tsx | 27 +- .../src/components/buttons/BlueButton.tsx | 14 - frontend/src/components/buttons/Button.tsx | 32 + .../src/components/buttons/DarkButton.tsx | 14 - .../src/components/video/ClipRangeSlider.tsx | 42 - frontend/src/components/video/ConfigBox.tsx | 84 +- .../src/components/video/ExportWidget.tsx | 70 +- frontend/src/components/video/MetadataBox.tsx | 10 +- .../src/components/video/PlaybackSlider.tsx | 37 - frontend/src/components/video/Playbar.tsx | 14 +- .../src/components/video/TrimTimeline.tsx | 283 +++++++ frontend/src/components/video/VideoCard.tsx | 106 ++- frontend/src/index.css | 91 +- frontend/src/layouts/MainLayout.tsx | 4 +- frontend/src/pages/ClipEdit.tsx | 147 ++-- frontend/src/pages/ClipUpload.tsx | 85 +- frontend/src/pages/EditClip.tsx | 59 +- frontend/src/pages/EditVod.tsx | 59 +- frontend/src/pages/Home.tsx | 163 +++- frontend/src/pages/Landing.tsx | 108 +++ frontend/src/pages/MediaPlayer.tsx | 144 +++- frontend/src/pages/MyClips.tsx | 40 +- frontend/src/pages/MyVods.tsx | 34 +- frontend/src/pages/Profile.tsx | 80 +- frontend/src/pages/StreamControl.tsx | 779 +++++++++++------- frontend/src/utils/api/stream.ts | 14 +- frontend/src/utils/styles.ts | 27 + frontend/src/utils/types.ts | 10 + frontend/src/utils/utils.ts | 41 +- .../vodsystem/controllers/ClipController.java | 6 +- .../controllers/StreamController.java | 56 ++ .../vodsystem/controllers/VodController.java | 6 +- .../controllers/dto/ClipResponse.java | 6 +- .../controllers/dto/IngestResponse.java | 4 + .../controllers/dto/VodResponse.java | 6 +- src/main/resources/application.properties | 1 + 46 files changed, 2022 insertions(+), 857 deletions(-) create mode 100644 frontend/src/components/GoogleSignIn.tsx create mode 100644 frontend/src/components/JobProgress.tsx create mode 100644 frontend/src/components/PageHeader.tsx delete mode 100644 frontend/src/components/buttons/BlueButton.tsx create mode 100644 frontend/src/components/buttons/Button.tsx delete mode 100644 frontend/src/components/buttons/DarkButton.tsx delete mode 100644 frontend/src/components/video/ClipRangeSlider.tsx delete mode 100644 frontend/src/components/video/PlaybackSlider.tsx create mode 100644 frontend/src/components/video/TrimTimeline.tsx create mode 100644 frontend/src/pages/Landing.tsx create mode 100644 frontend/src/utils/styles.ts create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/IngestResponse.java diff --git a/.env.example b/.env.example index a4e6dd5..8a144ae 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,5 @@ POSTGRES_DB=vodSystem FRONTEND_URL= BACKEND_URL= -STREAM_RING_LENGTH_SECOND=28800 \ No newline at end of file +STREAM_RING_LENGTH_SECOND=28800 +STREAM_INGEST_URL= \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c8ca22d..9a2769f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,6 @@ "lucide-react": "^0.511.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-range-slider-input": "^3.2.1", "react-router-dom": "^7.8.0" }, "devDependencies": { @@ -2974,17 +2973,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4365,29 +4353,6 @@ "react": "^19.2.8" } }, - "node_modules/react-range-slider-input": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz", - "integrity": "sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==", - "license": "MIT", - "dependencies": { - "clsx": "^1.1.1", - "core-js": "^3.22.4" - }, - "peerDependencies": { - "react": ">=17 || >=18", - "react-dom": ">=17 || >=18" - } - }, - "node_modules/react-range-slider-input/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 94de390..57b3402 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,6 @@ "lucide-react": "^0.511.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-range-slider-input": "^3.2.1", "react-router-dom": "^7.8.0" }, "devDependencies": { diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index b7ffc48..0d06ed7 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -6,7 +6,7 @@ type Props = { }; const Card = ({ children, className }: Props) => ( -
+
{children}
); diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index db384e0..a4ca052 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -9,7 +9,7 @@ type DropdownItemProps = { const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
  • onClick(item)} > {item} @@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
    {isOpen && ( -
      +
        {children}
      )} diff --git a/frontend/src/components/GoogleSignIn.tsx b/frontend/src/components/GoogleSignIn.tsx new file mode 100644 index 0000000..b03f6ff --- /dev/null +++ b/frontend/src/components/GoogleSignIn.tsx @@ -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 ( + + ); +}; + +export default GoogleSignIn; diff --git a/frontend/src/components/IsLivePulse.tsx b/frontend/src/components/IsLivePulse.tsx index 7359af8..cfb6c7b 100644 --- a/frontend/src/components/IsLivePulse.tsx +++ b/frontend/src/components/IsLivePulse.tsx @@ -2,7 +2,7 @@ const IsLivePulse = () => { return ( diff --git a/frontend/src/components/JobProgress.tsx b/frontend/src/components/JobProgress.tsx new file mode 100644 index 0000000..aeb029d --- /dev/null +++ b/frontend/src/components/JobProgress.tsx @@ -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 ( +
      +

      {job.label}

      + +
      +
      +
      + +
      +

      + {stageOf(job)} +

      +

      + {failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`} +

      +
      +
      + ); +}; + +export default JobProgress; +export { isSettled }; +export type { TrackedJob }; diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx new file mode 100644 index 0000000..4a97f5e --- /dev/null +++ b/frontend/src/components/PageHeader.tsx @@ -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) => ( +
      +
      +

      {kicker}

      +

      {title}

      +
      + {children} +
      +); + +export default PageHeader; diff --git a/frontend/src/components/Selector.tsx b/frontend/src/components/Selector.tsx index 8eab14f..83b681a 100644 --- a/frontend/src/components/Selector.tsx +++ b/frontend/src/components/Selector.tsx @@ -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) => ( -
      - -
      - {children} +
      + +
      {children}
      ); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7e3002f..fdccae3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 = () =>
      ; 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) => { ) : (
      - handleLogin(credentialResponse - )} - /> +
      )}
      diff --git a/frontend/src/components/buttons/BlueButton.tsx b/frontend/src/components/buttons/BlueButton.tsx deleted file mode 100644 index 4dce83e..0000000 --- a/frontend/src/components/buttons/BlueButton.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -type Props = React.ButtonHTMLAttributes; - -const BlueButton: React.FC = ({ className = "", ...props }) => { - return ( - + +
      +
      +
      +
      +
      +

      {stageOf(progress, uploading, downloadable)}

      +

      {downloadable ? "done" : `${pct}%`}

      +
      + + {downloadable && ( + + )}
      ) } diff --git a/frontend/src/components/video/MetadataBox.tsx b/frontend/src/components/video/MetadataBox.tsx index 6b1e6be..bc967a1 100644 --- a/frontend/src/components/video/MetadataBox.tsx +++ b/frontend/src/components/video/MetadataBox.tsx @@ -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>; @@ -8,13 +9,14 @@ type Props = { }; const MetadataBox = ({ setMetadata, className }: Props) => ( -
      -

      Title

      +
      + 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} />
      ); diff --git a/frontend/src/components/video/PlaybackSlider.tsx b/frontend/src/components/video/PlaybackSlider.tsx deleted file mode 100644 index 092db00..0000000 --- a/frontend/src/components/video/PlaybackSlider.tsx +++ /dev/null @@ -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>; - className?: string; -}; - -export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) { - const updateVideo = (e: React.ChangeEvent) => { - 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 ( - - ); -} diff --git a/frontend/src/components/video/Playbar.tsx b/frontend/src/components/video/Playbar.tsx index 204e2c5..b828f60 100644 --- a/frontend/src/components/video/Playbar.tsx +++ b/frontend/src/components/video/Playbar.tsx @@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) { }, [video]); return ( -
      -
      - +
      +
      +
      - + {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
      diff --git a/frontend/src/components/video/TrimTimeline.tsx b/frontend/src/components/video/TrimTimeline.tsx new file mode 100644 index 0000000..ad127f9 --- /dev/null +++ b/frontend/src/components/video/TrimTimeline.tsx @@ -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([0, duration]); + // Which thing the pointer is currently dragging, if any. + const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null); + + const trackRef = useRef(null); + const overviewRef = useRef(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 ( +
      + {/* Overview — the whole source, with the zoom window drawn on top. */} +
      { + 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" + > +
      +
      +
      +
      + + {/* Zoom controls */} +
      + + + + + {zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`} + {" · "} + {formatTime(viewStart)} – {formatTime(viewEnd)} + + +
      + + + +
      + + {/* Zoomed track */} +
      { + onSeek(toSeconds(event.clientX, trackRef.current, view)); + beginDrag("playhead", event); + }} + className="relative h-14 rounded-chip bg-fields cursor-pointer" + > + {ticks.map((at) => ( +
      +
      + + {formatTime(at)} + +
      + ))} + + {/* Trim band */} +
      + + {/* Playhead */} +
      + + {/* 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 ( +
      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}%` }} + > +
      +
      +
      + ); + })} +
      +
      + ); +}; + +export default TrimTimeline; +export type { Range }; diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index fea305d..81c4508 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -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 = { + 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(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 ( -
      -
      - Video Thumbnail +
      +
      + {thumbnailAvailable && ( + setThumbnailAvailable(false)} + /> + )} - + + {slug} + + + {formatTime(duration)} @@ -82,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className > {menuOpen && ( -
      +
      {!confirmDelete && (<> {onEdit && ( @@ -101,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className {onDelete && ( @@ -109,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className )} {confirmDelete && (
      -

      Delete this {itemLabel}?

      +

      Delete this {itemLabel}?

      @@ -132,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className )}
      -
      -

      - {title === "" ? "(No Title)" : title} +

      +

      + {title === "" ? "(No title)" : title}

      -

      {timeAgo}

      + +

      + {meta} +

      + + {facts.length > 0 && ( +

      + {facts.join(" · ")} +

      + )}
      diff --git a/frontend/src/index.css b/frontend/src/index.css index d5fe130..d8491e6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,33 +2,93 @@ @plugin "flowbite-react/plugin/tailwindcss"; @theme { - /* Fonts */ - /*--font-heading: "Newsreader", serif; */ - --font-body: "IBM Plex Sans", sans-serif; + /* ── Fonts ───────────────────────────────────────────────────────────── */ + --font-body: "IBM Plex Sans", system-ui, sans-serif; --font-data: "IBM Plex Mono", monospace; - /* Colours */ + /* ── Surfaces ────────────────────────────────────────────────────────── */ --color-background: #F7F4EE; --color-sidebar: #F2EEE6; --color-card: #FDFBF7; - --color-accent: #A85A38; - --color-accent-hover: #94492A; - --color-live-card: #F0EBE1; --color-fields: #F4F0E8; + --color-panel: #F1ECE3; + --color-dropzone: #F9F6EF; + --color-armed: #F6EFE6; + --color-live-card: #F0EBE1; + --color-player: #2B2620; + --color-player-stripe: #3A342C; - /* Colour Fonts */ + /* ── Ink ─────────────────────────────────────────────────────────────── */ --color-text-primary: #2B2620; --color-text-strong: #35302A; - --color-faint-mono: #A79C8E; + --color-text-body: #554D44; + --color-text-control: #4A423A; + --color-text-secondary: #6E6559; + --color-text-table: #7A7166; --color-muted: #9A9084; - --color-hover: #E7E0D2; - --color-hairline: #F0EBE2; - --color-live-red: #B5432E; + --color-faint-mono: #A79C8E; --color-on-dark: #FBF8F2; + --color-on-accent: #FDFBF7; + + /* ── Accents ─────────────────────────────────────────────────────────── */ + --color-accent: #A85A38; + --color-accent-hover: #94492A; + --color-accent-light: #C87A52; + --color-accent-disabled: #C9AE9C; + --color-olive: #6E7A50; + --color-olive-hover: #5D6843; + --color-olive-light: #8A9668; + --color-live-red: #B5432E; + + /* ── Lines, chips, placeholders ──────────────────────────────────────── */ + --color-hairline: #F0EBE2; + --color-hover: #E7E0D2; + --color-chip: #EAE3D7; + --color-chip-hover: #DAD1C0; + --color-ring-dashed: #E2DACB; + --color-inactive: #DED7CA; + --color-inactive-strong: #C4BAA9; + + /* ── Elevation — soft shadows instead of borders ─────────────────────── */ + --shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35); + --shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45); + --shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3); + --shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5); + --shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55); + --shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55); + + --inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08); + --inset-shadow-armed: inset 0 0 0 1.5px #A85A38; + --inset-shadow-focus: inset 0 0 0 1.5px #C87A52; + --inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB; + + /* ── Shape ───────────────────────────────────────────────────────────── */ + --radius-hero: 16px; + --radius-panel: 15px; + --radius-tile: 13px; + --radius-thumb: 12px; + --radius-field: 10px; + --radius-btn: 9px; + --radius-chip: 8px; + + /* ── Motion — the live dot, plus the player's one-shot action badge ──── */ + --animate-live-pulse: livePulse 2.2s ease-in-out infinite; + --animate-flash-badge: flashBadge 500ms ease-out forwards; + + @keyframes livePulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .45; transform: scale(1.35); } + } + + @keyframes flashBadge { + 0% { opacity: 0; transform: scale(.75); } + 25% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(1.4); } + } } :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; font-weight: 400; font-synthesis: none; @@ -39,11 +99,12 @@ body { background-color: var(--color-background); - font-family: "IBM Plex Sans", sans-serif; + 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: #2B2620; -} \ No newline at end of file +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 87425bd..4a0d5b8 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -7,10 +7,10 @@ const MainLayout = () => { const [sidebarToggled] = useState(false); return ( -
      +
      -
      +
      diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index 084ea0d..5b31761 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -1,8 +1,7 @@ import { useLocation } from 'react-router-dom'; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Playbar from "./../components/video/Playbar"; -import PlaybackSlider from "./../components/video/PlaybackSlider"; -import ClipRangeSlider from "./../components/video/ClipRangeSlider"; +import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; import { compress } from "../utils/api/media" @@ -10,6 +9,9 @@ import { getJob } from "../utils/api/jobs" import type { VideoMetadata } from "../utils/types.ts"; import Card from "../components/Card.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; +import PageHeader from "../components/PageHeader.tsx"; +import { formatTime, formatTimecode } from "../utils/utils.ts"; +import { kickerClass } from "../utils/styles.ts"; const API_URL = import.meta.env.VITE_API_URL; @@ -23,6 +25,7 @@ const ClipEdit = () => { const [metadata, setMetadata] = useState(null); const [playbackValue, setPlaybackValue] = useState(0); + const [range, setRange] = useState([0, 5]); const [outputMetadata, setOutputMetadata] = useState({ title: "", description: "", @@ -58,8 +61,30 @@ const ClipEdit = () => { fps: 30, fileSize: localFile?.size ?? 0, }); + // Start with the whole thing trimmed in — narrowing is the common move. + setRange([0, video.duration]); + setOutputMetadata(prev => ({ ...prev, startPoint: 0, duration: video.duration })); }; + // Keep the playhead in step with the video while it plays. + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const onTime = () => setPlaybackValue(video.currentTime); + video.addEventListener("timeupdate", onTime); + return () => video.removeEventListener("timeupdate", onTime); + }, [videoUrl]); + + const handleRangeChange = useCallback((next: Range) => { + setRange(next); + setOutputMetadata(prev => ({ ...prev, startPoint: next[0], duration: next[1] - next[0] })); + }, []); + + const handleSeek = useCallback((seconds: number) => { + setPlaybackValue(seconds); + if (videoRef.current) videoRef.current.currentTime = seconds; + }, []); + const sendData = async () => { if (!localFile || uploading) return; @@ -79,7 +104,7 @@ const ClipEdit = () => { const interval = setInterval(async () => await pollProgress(jobId, interval), 500); }; - const pollProgress = async (jobId: string, intervalId: number) => { + const pollProgress = async (jobId: string, intervalId: ReturnType) => { getJob(jobId) .then((job) => { setProgress(job.progress); @@ -124,60 +149,78 @@ const ClipEdit = () => { }; return ( -
      -