Merge pull request 'Update prod to master' (#4) from master into prod

Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
2026-06-25 23:28:51 +01:00
128 changed files with 2858 additions and 1894 deletions

View File

@@ -1,3 +1,18 @@
# Secrets
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
JWT_SECRET_KEY=
# Volumes
POSTGRES_DATA=
STREAM_DATA=
MEDIA_DATA=
# DB Credentials
POSTGRES_URL=jdbc:postgresql://postgres:5432/vodSystem
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
POSTGRES_DB=vodSystem
FRONTEND_URL=
BACKEND_URL=

5
.gitignore vendored
View File

@@ -39,6 +39,9 @@ build/
.vscode/
### Generated Files ###
videos/
/videos
/outputs
frontend/.flowbite-react
generated
.mvn

View File

@@ -1,19 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
wrapperVersion=3.3.2
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

View File

@@ -0,0 +1,16 @@
meta {
name: Delete Clip
type: http
seq: 5
}
delete {
url: {{base_url}}/clips/6
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip Media
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5/media
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Clip Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/clips/5/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 4
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,7 +1,7 @@
meta {
name: Get all clips
type: http
seq: 1
seq: 2
}
get {

View File

@@ -0,0 +1,22 @@
meta {
name: Update Clip
type: http
seq: 3
}
patch {
url: {{base_url}}/clips/5
body: json
auth: inherit
}
body:json {
{
"title": "NOT BZ"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: Clips
seq: 1
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Download
type: http
seq: 2
}
get {
url: {{base_url}}/download/output/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Download
type: http
seq: 1
}
get {
url: {{base_url}}/download/input/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Download
}

View File

@@ -1,17 +0,0 @@
meta {
name: Edit
type: http
seq: 1
}
post {
url: {{base_url}}/edit/{{uuid}}
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
startPoint: 10
duration: 40
title: best possible title
}

View File

@@ -1,11 +0,0 @@
meta {
name: Process
type: http
seq: 2
}
get {
url: {{base_url}}/process/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Progress
type: http
seq: 3
}
get {
url: {{base_url}}/progress/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Edit
}

View File

@@ -0,0 +1,15 @@
meta {
name: Download Video
type: http
seq: 2
}
get {
url: {{base_url}}/jobs/{{uuid}}/download
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Job
type: http
seq: 1
}
get {
url: {{base_url}}/jobs/{{uuid}}
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: Jobs
seq: 5
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,21 @@
meta {
name: Clip Stream
type: http
seq: 3
}
post {
url: {{base_url}}/media/clip
body: json
auth: inherit
}
body:json {
{
"duration": 60.0
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,22 @@
meta {
name: Compress
type: http
seq: 1
}
post {
url: {{base_url}}/media/compress
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4)
title: hello
startPoint: 10
duration: 30
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,22 @@
meta {
name: Save Stream Section
type: http
seq: 2
}
post {
url: {{base_url}}/media/save
body: json
auth: inherit
}
body:json {
{
"startTime": "2026-06-21T17:27:00Z",
"endTime": "2026-06-21T17:28:00Z"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,8 @@
meta {
name: Media
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Metadata
type: http
seq: 2
}
get {
url: {{base_url}}/metadata/converted/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Metadata
type: http
seq: 1
}
get {
url: {{base_url}}/metadata/original/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Metadata
}

View File

@@ -0,0 +1,20 @@
meta {
name: History Streams
type: http
seq: 1
}
get {
url: {{base_url}}/stream/history/1
body: none
auth: bearer
}
auth:bearer {
token: {{token}}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: Streams
seq: 4
}
auth {
mode: inherit
}

View File

@@ -1,19 +0,0 @@
meta {
name: Upload
type: http
seq: 1
}
post {
url: {{base_url}}/upload
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4)
}
script:post-response {
bru.setEnvVar("uuid", res.body.data.uuid);
}

View File

@@ -5,7 +5,7 @@ meta {
}
get {
url: {{base_url}}/auth/user
url: {{base_url}}/users/me
body: none
auth: inherit
}

View File

@@ -5,7 +5,7 @@ meta {
}
post {
url: {{base_url}}/auth/login
url: {{base_url}}/users/login
body: json
auth: none
}

View File

@@ -0,0 +1,8 @@
meta {
name: Users
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,7 @@
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}

32
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,32 @@
# Local dev: only infrastructure services
# Backend and frontend run directly on the host
# Usage: docker compose -f docker-compose.dev.yml up
services:
postgres:
image: postgres:15
container_name: vod_postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data
ports:
- "5432:5432"
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- WEB_SERVER_URL=http://host.docker.internal:8080
ports:
- "1935:1935"
volumes:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped

View File

@@ -1,16 +1,13 @@
services:
postgres:
image: postgres:15
container_name: vod_postgres
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: vodSystem
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ${POSTGRES_DATA}:/var/lib/postgresql/data
backend:
build:
@@ -21,6 +18,9 @@ services:
- postgres
env_file:
- .env
volumes:
- ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/app/stream
ports:
- "8080:8080"
restart: unless-stopped
@@ -30,13 +30,27 @@ services:
context: ./frontend
dockerfile: Dockerfile
container_name: vod_frontend
env_file:
- .env
environment:
VITE_API_URL: ${BACKEND_URL}
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
depends_on:
- backend
ports:
- "5173:5173"
restart: unless-stopped
volumes:
postgres_data:
rtmp:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: vod_rtmp
environment:
- WEB_SERVER_URL=http://backend:8080
ports:
- "1935:1935"
volumes:
- ${STREAM_DATA}:/stream
pull_policy: build
restart: unless-stopped
depends_on:
- backend

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
EXPOSE 5173

View File

@@ -7,6 +7,8 @@ import Home from './pages/Home';
import {useEffect} from "react";
import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx";
import EditClip from "./pages/EditClip.tsx";
import Profile from "./pages/Profile.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -25,6 +27,8 @@ function App() {
<Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/profile" element={<Profile />} />
</Route>
</Routes>
</Router>

View File

@@ -1,16 +1,14 @@
import clsx from "clsx";
type props = {
children: React.ReactNode,
className?: string
}
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({children, className}: props) => {
return (
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
{ children }
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{children}
</div>
)
}
);
export default Box;

View File

@@ -1,60 +1,51 @@
import React, {useEffect, useRef} from "react";
import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx";
type DropdownItemProps = {
item: string;
onClick: (item: string) => void;
className?: string;
}
};
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
return (
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)}
>
{item}
</li>
);
}
);
type DropdownProps = {
label: string;
label: React.ReactNode;
children: React.ReactNode;
className?: string;
}
};
const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false);
const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => {
function handleClickOutside(event: { target: any }) {
if (ref.current && !ref.current.contains(event.target)) {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className={clsx(className, "relative inline-block text-left")}
ref={ref}>
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button
onClick={toggleDropdown}
className={"inline-flex justify-between w-full rounded-xl px-4 py-2 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50"}
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
>
{label}
</button>
{isOpen && (
<ul className={"absolute w-30 origin-top-right rounded-md bg-white shadow-lg font-medium"}>
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{children}
</ul>
)}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
);
};
export { Dropdown, DropdownItem };

View File

@@ -1,21 +1,16 @@
import React from "react";
type props = {
type Props = {
children: React.ReactNode;
label: String;
}
label: string;
};
const Selector = ({children, label}: props) => {
return (
<div className={"flex items-center gap-2"}>
<label
className={"w-full"}>
{ label }
</label>
<div className={"w-px h-6 bg-gray-400 mx-3"} />
const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2">
<label className="w-full text-sm text-gray-600">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" />
{children}
</div>
)
}
);
export default Selector;

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home } from 'lucide-react';
import Box from "./Box.tsx";
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
import type {User} from "../utils/types.ts";
type props = {
@@ -9,34 +8,43 @@ type props = {
className?: string
}
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const Sidebar = ({user, className}: props) => {
return (
<Box className={clsx("h-screen flex flex-col mr-5", className)}>
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
<div className="px-4 pt-5 pb-3">
<img
className={"w-45 mx-auto"}
src={"../../public/logo.png"}
alt={"VoD System Logo"}
/>
<SidebarButton
url={"/"}
logo={<Home size={20}/>}
label={"Home"}
className="w-36 mx-auto"
src="../../public/logo.png"
alt="VoD System Logo"
/>
</div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div>
<SidebarButton
url={"/create"}
logo={<Plus size={20}/>}
label={"Create Clip"}
/>
{ user && (
<SidebarButton
url={"/my-clips"}
logo={<Film size={20}/>}
label={"My Clips"}
/>
{user && (
<>
<Divider />
<div className="flex flex-col gap-0.5">
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
</div>
</>
)}
</Box>
<div className="flex-1" />
{user && (
<>
<Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</nav>
</aside>
);
};

View File

@@ -16,6 +16,7 @@ type props = {
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
@@ -24,6 +25,7 @@ const Topbar = ({
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
@@ -56,7 +58,7 @@ const Topbar = ({
}
return (
<div className={clsx(className, "flex justify-between")}>
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
@@ -69,7 +71,19 @@ const Topbar = ({
referrerPolicy="no-referrer"
/>
<Dropdown label={user.name}>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import MenuButton from "./MenuButton.tsx";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
type props = {
type Props = {
url: string;
logo: React.ReactNode;
label: String;
label: string;
}
const SidebarButton = ({url, logo, label}: props) => {
const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
return (
<Link
className={"w-full"}
to={url}
>
<MenuButton className={"flex items-center gap-2 w-full"}>
<Link className="w-full" to={url}>
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
{logo}{label}
</MenuButton>
</Link>

View File

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

View File

@@ -1,46 +1,36 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx";
import clsx from "clsx";
type prop = {
setMetadata: Function;
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
}
};
export default function ConfigBox({setMetadata, className}: prop) {
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({
...prevState,
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height }));
};
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fps: parseInt(e.target.value)
}))
}
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
};
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({
...prevState,
fileSize: parseInt(e.target.value) * 1000
}))
}
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
};
return (
<div className={clsx("flex flex-col gap-2 p-10", className)}>
<h2 className={"text-xl font-bold"}>Export</h2>
<div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<Selector label={"Resolution"}>
<select id="resolution"
name="resolution"
defaultValue="1280,720"
onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720"
onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option>
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
</select>
</Selector>
<Selector label={"FPS"}>
<select id="fps"
name="fps"
defaultValue="30"
onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<Selector label="FPS">
<select id="fps" name="fps" defaultValue="30"
onChange={updateFps} className={inputClass}>
<option value="60">60</option>
<option value="30">30</option>
<option value="15">15</option>
</select>
</Selector>
<Selector label={"File Size Limit (MB)"}>
<input type="number"
min="1"
defaultValue="10"
onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
<Selector label="File Size Limit (MB)">
<input type="number" min="1" defaultValue="10"
onChange={updateFileSize} className={inputClass}
/>
</Selector>
</div>
)
);
}

View File

@@ -16,18 +16,20 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props)
Export
</BlueButton>
<div className="h-10 flex items-center">
{ downloadable ?
(<BlueButton
onClick={handleDownload}>
(<BlueButton className="w-full" onClick={handleDownload}>
Download
</BlueButton>)
:(
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress> )
className="w-full h-1.5 rounded bg-gray-200">
</progress>
)
}
</div>
</div>
)
}

View File

@@ -1,28 +1,22 @@
import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts";
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = {
setMetadata: Function
type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
}
};
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<p className={"w-full font-bold text-xl "}>Title</p>
const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
/>
</div>
)
}
);
export default MetadataBox;

View File

@@ -1,52 +1,37 @@
import {useEffect} from "react";
import clsx from 'clsx';
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: Function;
setSliderValue: Dispatch<SetStateAction<number>>;
className?: string;
};
export default function PlaybackSlider({videoRef,
videoMetadata,
sliderValue,
setSliderValue,
className}: Props) {
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);
}
};
// update slider
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => {
setSliderValue(videoRef.currentTime);
};
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => {
videoRef.removeEventListener("timeupdate", updateSlider);
};
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type={"range"}
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={clsx(className)}
className={className}
/>
)
);
}

View File

@@ -1,10 +1,9 @@
import { useEffect, useState} from "react";
import { useEffect, useState } from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts";
type Props = {
video: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => {
if (!video) return;
if (video.paused) {
video.play();
setIsPlaying(true);
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}
};
let Icon;
// update icon
if (volume == 0) {
Icon = VolumeX;
} else if (volume < 50) {
Icon = Volume1;
} else {
Icon = Volume2;
}
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
let volume = parseInt(e.target.value)
video.volume = volume / 100;
setVolume(volume);
}
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
useEffect(() => {
if (!video) return;
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
return () => {
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
return (
<div className={clsx("flex justify-between items-center mb-2", className)}>
<div className={"flex gap-1"}>
<div className="flex gap-1">
<Icon size={24} />
<input
type='range'
type="range"
min={0}
max={100}
onChange={updateVolume}
value={volume}
className={"w-20"}
onChange={updateVolume}
className="w-20"
/>
</div>
<button
onClick={togglePlay}
className={"hover:bg-gray-300 rounded-full p-1"}
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<label>
<span>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</label>
</span>
</div>
);
}

View File

@@ -1,79 +1,138 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import {useEffect, useState} from "react";
import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = {
id: number,
title: string,
duration: number,
createdAt: string,
className?: string
onEdit?: () => void,
onDelete?: () => void,
className?: string,
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => {
isThumbnailAvailable(id)
.then((available) => {
setThumbnailAvailable(available);
})
.catch(() => {
setThumbnailAvailable(false);
});
.then(setThumbnailAvailable)
.catch(() => setThumbnailAvailable(false));
}, []);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
setConfirmDelete(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
e.preventDefault();
e.stopPropagation();
fn();
};
return (
<Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}>
<div className={"relative inline-block"}>
<div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg">
<img
src={thumbnailAvailable ? `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/>
<p className="
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
<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">
{formatTime(duration)}
</p>
</span>
{(onEdit || onDelete) && (
<div
ref={menuRef}
className="absolute top-1.5 right-1.5"
onClick={(e) => e.preventDefault()}
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
>
<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">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil size={13} /> Edit
</button>
)}
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 size={13} /> Delete
</button>
)}
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this clip?</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"
>
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"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className={"flex flex-col justify-between p-2"}>
<p>{title == "" ? "(No Title)" : title}</p>
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
<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}
</p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div>
</div>
</Link>

View File

@@ -2,95 +2,44 @@
@plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme {
--font-display: "Satoshi", "sans-serif";
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Breakpoints */
--breakpoint-3xl: 1920px;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
--color-primary: oklch(0.55 0.21 254);
--color-primary-pressed: oklch(0.45 0.21 254);
--color-secondary: oklch(0.94 0.01 250);
--color-accent: oklch(0.68 0.20 288);
--color-text: oklch(0.17 0.01 270);
--color-background: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}
#range-slider .range-slider__range{
#range-slider .range-slider__range {
background: var(--color-primary);
}
#range-slider .range-slider__thumb{
#range-slider .range-slider__thumb {
background: var(--color-primary);
}
:root {
font-family: Helvetica;
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*a {*/
/* font-weight: 500;*/
/* color: #646cff;*/
/* text-decoration: inherit;*/
/*}*/
/*a:hover {*/
/* color: #535bf2;*/
/*}*/
/*body {*/
/* margin: 0;*/
/* display: flex;*/
/* place-items: center;*/
/* min-width: 320px;*/
/* min-height: 100vh;*/
/*}*/
/*h1 {*/
/* font-size: 3.2em;*/
/* line-height: 1.1;*/
/*}*/
/*button {*/
/* border-radius: 8px;*/
/* border: 1px solid transparent;*/
/* padding: 0.6em 1.2em;*/
/* font-size: 1em;*/
/* font-weight: 500;*/
/* font-family: inherit;*/
/* background-color: #1a1a1a;*/
/* cursor: pointer;*/
/* transition: border-color 0.25s;*/
/*}*/
/*button:hover {*/
/* border-color: #646cff;*/
/*}*/
/*button:focus,*/
/*button:focus-visible {*/
/* outline: 4px auto -webkit-focus-ring-color;*/
/*}*/
/*@media (prefers-color-scheme: light) {*/
/* :root {*/
/* color: #213547;*/
/* background-color: #ffffff;*/
/* }*/
/* a:hover {*/
/* color: #747bff;*/
/* }*/
/* button {*/
/* background-color: #f9f9f9;*/
/* }*/
/*}*/
body {
background-color: var(--color-background);
}

View File

@@ -4,11 +4,12 @@ import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import type {User} from "../utils/types";
import { getUser } from "../utils/endpoints";
import { getUser, getStreamStatus } from "../utils/endpoints";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => {
try {
@@ -23,6 +24,14 @@ const MainLayout = () => {
fetchUser();
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar
@@ -33,8 +42,9 @@ const MainLayout = () => {
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}/>
<div className="overflow-auto">
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet />
</div>
</div>

View File

@@ -1,23 +1,28 @@
import { useParams } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints"
import { compress, getJob } from "../utils/endpoints"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => {
const { id } = useParams();
const location = useLocation();
const localFile: File = location.state?.file;
const videoRef = useRef<HTMLVideoElement | null>(null);
const videoUrl = `/api/v1/download/input/${id}`
const [videoUrl, setVideoUrl] = useState<string>('');
const [uploadedId, setUploadedId] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "",
description: "",
startPoint: 0,
@@ -31,41 +36,66 @@ const ClipEdit = () => {
const [downloadable, setDownloadable] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendData = async() => {
if (!id) return;
useEffect(() => {
if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
editFile(id, outputMetadata)
.then(() => {
const handleVideoMetadataLoaded = () => {
const video = videoRef.current;
if (!video) return;
setMetadata({
title: '',
description: '',
startPoint: 0,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
fps: 30,
fileSize: localFile?.size ?? 0,
});
};
processFile(id)
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
const sendData = async () => {
if (!localFile) return;
})
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async() => await pollProgress(id, interval), 500);
let jobId: string;
try {
jobId = await compress(localFile, outputMetadata);
setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
return;
}
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.process.progress);
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
};
if (progress.process.complete) {
const pollProgress = async (jobId: string, intervalId: number) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
} else if (job.isComplete) {
clearInterval(intervalId);
setDownloadable(true);
} else {
setDownloadable(false)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
};
const handleDownload = async () => {
const response = await fetch(`/api/v1/download/output/${id}`);
if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) {
console.error('Download failed');
@@ -77,43 +107,27 @@ const ClipEdit = () => {
const a = document.createElement('a');
a.href = url;
a.download = `${id}.mp4`;
a.download = `${uploadedId}.mp4`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
};
useEffect(() => {
if (!id) return;
getMetadata(id)
.then((data) => setMetadata(data ?? null))
.catch((err) => console.error("Metadata fetch failed:", err));
}, [id]);
return (
<div className={"grid grid-cols-[7fr_3fr]"}>
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video
ref={videoRef}
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
<source src={videoUrl} type="video/mp4" />
<source src={videoUrl} type="video/webm" />
<source src={videoUrl} type="video/ogg" />
Your browser does not support the video tag. Bzzzz.
</video>
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Box className={"w-4/5 h-full m-auto"}>
<MetadataBox
setMetadata={setOutputMetadata}
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
<MetadataBox setMetadata={setOutputMetadata} />
<ConfigBox setMetadata={setOutputMetadata} />
</Box>
{metadata &&
<Box className={"mt-4 p-5"}>
<Playbar
@@ -156,6 +170,6 @@ const ClipEdit = () => {
</Box>
</div>
);
}
};
export default ClipEdit;

View File

@@ -1,6 +1,5 @@
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx";
@@ -8,90 +7,35 @@ const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate();
const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => {
const press = () => {
if (!file) {
setError("Please choose a file");
return;
}
uploadFile(file)
.then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
navigate('/create/new', { state: { file } });
};
return (
<Box className={"flex flex-col justify-between gap-3 p-5"}>
<div className="flex items-start justify-center p-8 min-h-full">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<input
type="file"
onChange={(e) => {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
setError(null);
}}
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"}
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/>
<BlueButton
onClick={press}>
Upload
</BlueButton>
<BlueButton onClick={press}>Upload</BlueButton>
<label className={"text-center text-red-500"}>{error}</label>
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
</Box>
</div>
)
};

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
const EditClip = () => {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
getClipById(id)
.then((clip) => {
if (clip) {
setTitle(clip.title ?? "");
setDescription(clip.description ?? "");
}
})
.catch(() => setError("Failed to load clip."))
.finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
setError(null);
try {
await patchClip(Number(id), { title, description });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save.");
} finally {
setSaving(false);
}
};
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Box>
)}
</div>
);
};
export default EditClip;

View File

@@ -1,43 +1,78 @@
const Home = () => {
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
const features = [
{
icon: Upload,
title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
},
{
icon: Scissors,
title: "Trim",
description: "Set start and end points with a range slider. Preview as you go.",
},
{
icon: SlidersHorizontal,
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.",
},
{
icon: Radio,
title: "RTMP streaming",
description: "Point OBS at your stream key and go live. Find the key in your profile.",
},
{
icon: LayoutGrid,
title: "Clip library",
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
},
{
icon: Play,
title: "Built-in player",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
},
];
const Home = () => {
return (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
{/* Logo */}
<div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img
src="/logo.png"
alt="VoD System Logo"
className={"h-44 mb-8 object-contain"}
className="h-24 mb-6 object-contain"
/>
{/* Description Container */}
<div className={"max-w-xl text-center"}>
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
What is the VoD System?
</h2>
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
The VoD System is a powerful clip management platform designed to streamline how you
handle your video content. Whether you're a content creator, streamer, or educator,
VoD System lets you:
</p>
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
<li>Upload clips effortlessly and securely.</li>
<li>Edit and trim videos with intuitive controls.</li>
<li>Compress files to specific file sizes.</li>
<li>Organize your clips for quick access and sharing.</li>
</ul>
<p className={"text-lg leading-relaxed text-gray-600"}>
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
making video clip management faster and more enjoyable than ever.
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
A home for your clips.
</h1>
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div>
{/* File Support Note */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}>
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
Support for additional video formats will be added in future updates.
{/* Feature grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
{features.map(({ icon: Icon, title, description }) => (
<div
key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div>
<div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div>
);
};

View File

@@ -1,31 +1,55 @@
import VideoCard from "../components/video/VideoCard";
import {useEffect, useState} from "react";
import { getClips } from "../utils/endpoints";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/endpoints";
import type { Clip } from "../utils/types";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => {
getClips()
.then((data) => setClips(data))
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err));
}, []);
const handleDelete = async (id: number) => {
try {
await deleteClip(id);
setClips(prev => prev.filter(c => c.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete clip");
}
};
return (
<div className={"flex flex-row"}>
<div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
className={"w-40 m-5"}
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/>
))}
</div>
{error}
{clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
import { getUser } from "../utils/endpoints.ts";
import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => {
const [user, setUser] = useState<User | null>(null);
const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
getUser().then(setUser).catch(console.error);
}, []);
const handleCopy = () => {
if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to view your profile.
</div>
);
}
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
<Box className="p-6 mb-4 flex items-center gap-5">
<img
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt="Profile picture"
className="w-20 h-20 rounded-full shadow-sm"
/>
<div>
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400 mt-1">
Member since {formatLocalDate(user.createdAt)}
</p>
</div>
</Box>
<Box className="p-6">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming.
Keep it private — anyone with this key can stream to your account.
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
</div>
<button
onClick={() => setKeyVisible(v => !v)}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<button
onClick={handleCopy}
title="Copy key"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button>
</div>
</Box>
</div>
);
};
export default Profile;

View File

@@ -1,76 +1,326 @@
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import type {Clip} from "../utils/types";
import {getClipById, getVideoBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx"
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import type { Clip } from "../utils/types";
import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
const VideoPlayer = () => {
const { id } = useParams();
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
const [clip, setClip] = useState<Clip | null>(null);
const [timeAgo, setTimeAgo] = useState<String>("");
const [timeAgo, setTimeAgo] = useState("");
// Playback
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
// Volume
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// UI
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
// ── Data loading ────────────────────────────────────────────────────────
useEffect(() => {
if (!id) {
setError("Clip ID is required.");
return;
}
if (!id) { setError("Clip ID is required."); return; }
getVideoBlob(id)
.then((blob) => {
const url = URL.createObjectURL(blob);
setVideoUrl(url);
})
.then((blob) => setVideoUrl(URL.createObjectURL(blob)))
.catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)})
.catch((err) => {
console.error("Error fetching clip:", err);
setError("Failed to load clip details. Please try again later.");
.then(setClip)
.catch((e) => {
if (e instanceof AuthError) setNotAuthenticated(true);
else setError("Failed to load clip details.");
});
}, [id]);
// Update timeAgo live every second
useEffect(() => {
if (!clip || !clip.createdAt) return;
const update = () => {
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
if (!clip?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval); // cleanup
return () => clearInterval(interval);
}, [clip]);
// ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
}, []);
const showControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
document.fullscreenElement
? document.exitFullscreen()
: containerRef.current.requestFullscreen();
}, []);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const video = videoRef.current;
if (!video) return;
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
video.paused ? video.play() : video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showControls();
break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
}, [duration]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
const onUp = () => setIsDragging(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isDragging, seekTo]);
// ── Video element event handlers ────────────────────────────────────────
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime);
if (video.buffered.length > 0) {
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
if (notAuthenticated) {
return (
<div className={"w-9/10 m-auto"}>
<video
className={"w-full h-full rounded-lg m-auto"}
controls
autoPlay
src={videoUrl}
onError={(e) => {
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
}}
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
<Lock size={24} className="text-gray-400" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
</div>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Your browser does not support the video tag.
</video>
<ArrowLeft size={16} />
Go back
</button>
</div>
);
}
{error && <div className="text-red-500 mt-2">{error}</div>}
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
return (
<div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<Box className={"p-2 m-2 flex flex-col"}>
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
<p>{timeAgo}</p>
{/* Player */}
<div
ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
src={videoUrl}
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => {
setIsPlaying(false);
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/>
{/* Buffering spinner */}
{isWaiting && videoUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
{/* Controls bar — pointer-events-auto only here */}
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-white/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
</div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!videoUrl && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box>
</div>
);
};

View File

@@ -1,279 +1,172 @@
import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts";
import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
const API_URL = import.meta.env.VITE_API_URL;
/**
* Login function
* @param GoogleToken - The Google token received from the frontend.
* @return A promise that resolves to a JWT
*/
const login = async (GoogleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/auth/login', {
export class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; }
}
const login = async (googleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: GoogleToken }),
credentials: 'include'
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: googleToken }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Login failed: ${response.status}`);
}
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error(`Login failed: ${result.message}`);
}
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
}
};
const logout = async () => {
const response = await fetch(API_URL + '/api/v1/auth/logout', {
const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
credentials: 'include'
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Logout failed: ${response.status}`);
}
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
if (result.status === "error") {
throw new Error(`Logout failed: ${result.message}`);
}
}
/**
* Uploads a file to the backend.
* @param file - The file to upload.
*/
const uploadFile = async (file: File): Promise<string> => {
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('title', options.title);
if (options.description) formData.append('description', options.description);
formData.append('startPoint', options.startPoint.toString());
formData.append('duration', options.duration.toString());
if (options.fps) formData.append('fps', options.fps.toString());
if (options.width) formData.append('width', options.width.toString());
if (options.height) formData.append('height', options.height.toString());
if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
const response = await fetch(API_URL + '/api/v1/upload', {
const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST',
body: formData,
credentials: 'include',
});
const result: APIResponse = await response.json();
if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
if (result.status == "error") {
throw new Error(`Failed to upload file: ${result.message}`);
}
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
return result.data.uuid;
};
/**
* Submits metadata changes to the backend.
* @param uuid - The UUID of the video file to edit.
* @param videoMetadata - The metadata to update.
*/
const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
const formData = new URLSearchParams();
const getJob = async (uuid: string): Promise<JobResponse> => {
const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
for (const [key, value] of Object.entries(videoMetadata)) {
if (value !== undefined && value !== null) {
formData.append(key, value.toString());
}
}
const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`Failed to edit file: ${response.status}`);
}
if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error(`Failed to edit file: ${result.message}`);
}
};
/**
* Triggers file processing.
* @param uuid - The UUID of the video file to process.
*/
const processFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"});
if (!response.ok) {
throw new Error(`Failed to process file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to process file: " + result.message);
}
};
const convertFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/convert/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to convert file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to convert file: " + result.message);
}
};
/**
* Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/
const getProgress = async (uuid: string): Promise<ProgressResult> => {
const response = await fetch(API_URL + `/api/v1/progress/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to fetch progress: ${response.status}`);
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data) {
throw new Error('Invalid progress data received');
}
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
return result.data;
};
/**
* Fetches original metadata from the backend.
* @param uuid - The UUID of the video file.
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
const getUser = async (): Promise<User | null> => {
const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
if (!response.ok) return null;
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.status}`);
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
const result: APIResponse = await response.json();
if (result.status === 'error') return null;
return result.data;
};
/**
* Fetches the current user information. Returns null if not authenticated.
*/
const getUser = async (): Promise<null | User > => {
const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"});
if (!response.ok) {
return null;
}
const result = await response.json();
if (result.status === "error") {
return null;
}
console.log(result.data);
return result.data;
}
/**
* Fetches all clips for the current user.
*/
const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include'});
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
try {
const result: APIResponse = await response.json();
return result.data;
} catch {
throw new Error('Failed to parse response');
}
}
};
/**
* Fetches a clip by its ID.
* @param id
*/
const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",});
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`);
}
try{
const result: APIResponse = await response.json();
return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const getVideoBlob = async(id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",});
const getVideoBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) {
throw new Error(`Failed to fetch video: ${id}: ${response.status}`)
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
}
try {
return response.blob();
} catch {
throw new Error(`Failed to convert Clip Return Object to blob`);
}
}
};
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
};
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
if (!response.ok) {
return false;
}
return true;
}
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
return response.ok;
};
export {
login,
logout,
uploadFile,
editFile,
processFile,
convertFile,
getProgress,
getMetadata,
compress,
getJob,
getUser,
getClips,
getClipById,
getVideoBlob,
isThumbnailAvailable
patchClip,
deleteClip,
getStreamStatus,
isThumbnailAvailable,
};

View File

@@ -17,8 +17,11 @@ type APIResponse = {
type User = {
name: string,
username: string,
email: string,
profilePictureUrl: string
profilePictureUrl: string,
streamKey: string,
createdAt: string,
}
type Clip = {
@@ -30,15 +33,13 @@ type Clip = {
createdAt: string,
}
type ProgressResult = {
process: {
type JobResponse = {
uuid: string,
progress: number,
complete: boolean
};
conversion: {
progress: number,
complete: boolean
};
isComplete: boolean,
state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED',
errorOutput: string | null,
createdAt: string,
};
export type {
@@ -46,5 +47,5 @@ export type {
VideoMetadata,
User,
Clip,
ProgressResult
JobResponse
}

View File

@@ -2,76 +2,42 @@ function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
const pad = (n: number) => n.toString().padStart(2, '0');
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
}
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23);
const date = new Date(validIso);
if (isNaN(date.getTime())) {
throw new Error("Invalid date string");
}
const normalized = dateString.replace(/(\.\d{3})\d+/, "$1");
const withTz = /[Zz]$|[+-]\d{2}:\d{2}$|[+-]\d{4}$/.test(normalized) ? normalized : `${normalized}Z`;
const date = new Date(withTz);
if (isNaN(date.getTime())) throw new Error("Invalid date string");
return date;
}
function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOptions): string {
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
function dateToTimeAgo(date: Date): string {
const now = new Date();
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000);
if (secondsAgo < 60) {
return `${secondsAgo} seconds ago`;
} else if (secondsAgo < 3600) {
const minutes = Math.floor(secondsAgo / 60);
if (minutes === 1) {
return "1 minute ago";
const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const units: [number, string][] = [
[31536000, 'year'],
[2592000, 'month'],
[86400, 'day'],
[3600, 'hour'],
[60, 'minute'],
];
for (const [threshold, unit] of units) {
if (secondsAgo >= threshold) {
const count = Math.floor(secondsAgo / threshold);
return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
}
return `${minutes} minutes ago`;
} else if (secondsAgo < 86400) {
const hours = Math.floor(secondsAgo / 3600);
if (hours === 1) {
return "1 hour ago";
}
return `${hours} hours ago`;
} else if (secondsAgo < 2592000) {
const days = Math.floor(secondsAgo / 86400);
if (days === 1) {
return "1 day ago";
}
return `${days} days ago`;
} else if (secondsAgo < 31536000) {
const months = Math.floor(secondsAgo / 2592000);
if (months === 1) {
return "1 month ago";
}
return `${months} months ago`;
} else {
const years = Math.floor(secondsAgo / 31536000);
if (years === 1) {
return "1 year ago";
}
return `${years} years ago`;
}
const s = Math.max(0, secondsAgo);
return `${s} second${s !== 1 ? 's' : ''} ago`;
}
export {
formatTime,
stringToDate,
dateToTimeAgo
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };

9
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM tiangolo/nginx-rtmp
RUN apt-get update && apt-get install -y gettext-base && rm -rf /var/lib/apt/lists/*
COPY default.conf.template /etc/nginx/templates/default.conf.template
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@@ -0,0 +1,48 @@
worker_processes auto;
events {
worker_connections 1024;
}
rtmp {
server {
listen 1935;
chunk_size 4096;
application live {
live on;
record off;
allow publish all;
allow play all;
hls on;
hls_nested on;
hls_path /stream;
hls_fragment_naming system;
hls_fragment 3;
hls_playlist_length 28800;
on_publish ${WEB_SERVER_URL}/api/v1/stream/start;
on_publish_done ${WEB_SERVER_URL}/api/v1/stream/stop;
on_update ${WEB_SERVER_URL}/api/v1/stream/heartbeat;
notify_update_timeout 30s;
}
}
}
# Serve the HLS segments over HTTP
http {
server {
listen 8088;
location /hls {
types {
application/vnd.apple.mpegurl m3u8; # proper MIME for HLS playlists
video/mp2t ts; # MPEG-TS segment MIME
}
root /stream;
add_header Cache-Control no-cache; # prevent stale segments
add_header Access-Control-Allow-Origin *; # CORS for browser playback
}
}
}

View File

@@ -0,0 +1,9 @@
#!/bin/sh
# must only run on certain env files, otherwise it replaces too much
envsubst '${WEB_SERVER_URL}' \
< /etc/nginx/templates/default.conf.template \
> /etc/nginx/nginx.conf
# daemon off required otherwise the container would exit immediately after nginx forks to background.
exec nginx -g "daemon off;"

View File

@@ -81,6 +81,10 @@
<artifactId>google-api-client</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -1,13 +1,18 @@
package com.ddf.vodsystem.configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class AsyncConfig {
public class AsyncConfig implements AsyncConfigurer {
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
@Bean(name = "ffmpegTaskExecutor")
public Executor taskExecutor() {
@@ -19,4 +24,10 @@ public class AsyncConfig {
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
logger.error("Async task '{}' failed: {}", method.getName(), ex.getMessage(), ex);
}
}

View File

@@ -19,7 +19,7 @@ public class CorsConfig {
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/v1/**")
.allowedOrigins(frontendUrl)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
}

View File

@@ -30,11 +30,11 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll()
.requestMatchers("/api/v1/metadata/**").permitAll()
.requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

View File

@@ -1,9 +1,14 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipDTO;
import com.ddf.vodsystem.controllers.dto.ClipResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -15,15 +20,16 @@ import java.util.Optional;
public class ClipController {
private final ClipService clipService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) {
this.clipService = clipService;
}
@GetMapping("")
public ResponseEntity<APIResponse<List<ClipDTO>>> getClips() {
public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser();
List<ClipDTO> clipDTOs = clips.stream()
List<ClipResponse> clipDTOs = clips.stream()
.map(this::convertToDTO)
.toList();
@@ -36,13 +42,13 @@ public class ClipController {
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipDTO>> getClipById(@PathVariable Long id) {
public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Optional<Clip> clip = clipService.getClipById(id);
if (clip.isEmpty()) {
return ResponseEntity.notFound().build();
}
ClipDTO clipDTO = convertToDTO(clip.get());
ClipResponse clipDTO = convertToDTO(clip.get());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS,
@@ -52,6 +58,17 @@ public class ClipController {
);
}
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) {
Clip clip = clipService.updateClip(id, updateFields);
ClipResponse clipDTO = convertToDTO(clip);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Clip updated successfully", clipDTO)
);
}
@DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
if (!clipService.deleteClip(id)) {
@@ -67,14 +84,42 @@ public class ClipController {
);
}
private ClipDTO convertToDTO(Clip clip) {
ClipDTO dto = new ClipDTO();
dto.setId(clip.getId());
dto.setUserId(clip.getUser().getId());
dto.setTitle(clip.getTitle());
dto.setDescription(clip.getDescription());
dto.setDuration(clip.getDuration());
dto.setCreatedAt(clip.getCreatedAt());
return dto;
@GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = clipService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private ClipResponse convertToDTO(Clip clip) {
return new ClipResponse(
clip.getId(),
clip.getUser().getId(),
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
clip.getCreatedAt()
);
}
}

View File

@@ -1,81 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.services.DownloadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/download")
public class DownloadController {
private final DownloadService downloadService;
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
@Autowired
public DownloadController(DownloadService downloadService) {
this.downloadService = downloadService;
}
@GetMapping("/output/{uuid}")
public ResponseEntity<Resource> downloadFile(@PathVariable String uuid) {
Resource resource = downloadService.downloadOutput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/input/{uuid}")
public ResponseEntity<Resource> downloadInput(@PathVariable String uuid) {
Resource resource = downloadService.downloadInput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/clip/{id}")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = downloadService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/thumbnail/{id}")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = downloadService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -1,44 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.services.EditService;
import com.ddf.vodsystem.dto.APIResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("api/v1")
public class EditController {
private final EditService editService;
private static final String SUCCESS = "success";
public EditController(EditService editService) {
this.editService = editService;
}
@PostMapping("edit/{uuid}")
public ResponseEntity<APIResponse<Void>> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) {
editService.edit(uuid, clipOptions);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null));
}
@GetMapping("/process/{uuid}")
public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
editService.process(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null));
}
@GetMapping("/convert/{uuid}")
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) {
editService.convert(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null));
}
@GetMapping("/progress/{uuid}")
public ResponseEntity<APIResponse<JobStatus>> getStatus(@PathVariable("uuid") String uuid) {
JobStatus status = editService.getStatus(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status));
}
}

View File

@@ -1,10 +1,7 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.exceptions.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -76,4 +73,25 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
@ExceptionHandler(KeyNotFound.class)
public ResponseEntity<APIResponse<Void>> handleKeyNotFound(KeyNotFound ex) {
logger.error("KeyNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
@ExceptionHandler(AlreadyStreaming.class)
public ResponseEntity<APIResponse<Void>> handleAlreadyStreaming(AlreadyStreaming ex) {
logger.error("AlreadyStreaming: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
@ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e);
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}

View File

@@ -0,0 +1,70 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.JobResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.ClipNotFound;
import com.ddf.vodsystem.exceptions.NotReadyException;
import com.ddf.vodsystem.services.JobRegistryService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/jobs")
public class JobController {
private final JobRegistryService jobRegistryService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public JobController(JobRegistryService jobRegistryService){
this.jobRegistryService = jobRegistryService;
}
@GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
JobResponse jobResponse = new JobResponse(
job.getUuid(),
job.getProgressTracker().getProgress(),
job.getProgressTracker().isComplete(),
job.getState(),
job.getErrorOutput(),
job.getCreatedAt()
);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Job successfully retreived",
jobResponse
));
}
@GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
if (job.getDownload() == null) {
throw new NotReadyException("Job " + uuid + " not ready for download.");
}
Resource resource = new FileSystemResource(job.getDownload());
if (!resource.exists()) {
throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist.");
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -0,0 +1,63 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.MediaService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/api/v1/media")
public class MediaController {
private final MediaService mediaService;
private static final String SUCCESS = "success";
public MediaController(MediaService mediaService) {
this.mediaService = mediaService;
}
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file,
@Valid @ModelAttribute ClipOptions options
) throws IOException, InterruptedException {
Job job = mediaService.compress(file, options);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Compression successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Section saving successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
Job job = mediaService.clip(clipSectionRequest.duration());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Clipping successfully started",
new UUIDResponse(job.getUuid())
));
}
}

View File

@@ -1,47 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.JobService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/metadata")
public class MetadataController {
private final JobService jobService;
public MetadataController(JobService jobService) {
this.jobService = jobService;
}
@GetMapping("/original/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
ClipOptions originalMetadata = jobService.getJob(uuid).getInputClipOptions();
if (originalMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Original metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata));
}
@GetMapping("/converted/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getConvertedMetadata(@PathVariable String uuid) {
ClipOptions convertedMetadata = jobService.getJob(uuid).getOutputClipOptions();
if (convertedMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Converted metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata));
}
}

View File

@@ -0,0 +1,65 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/stream")
public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success";
private final StreamService streamService;
public StreamController(StreamService streamService) {
this.streamService = streamService;
}
// Called by nginx on_publish
@PostMapping("/start")
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
logger.info("Stream start callback for key: {}", streamKey);
Stream stream = streamService.startStream(streamKey);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
}
// Called by nginx on_publish_done
@PostMapping("/stop")
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
logger.info("Stream stop callback for key: {}", streamKey);
streamService.endStream(streamKey);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
}
// Called by nginx on_update
@PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
streamService.heartbeatStream(streamKey);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Heartbeat recorded", null));
}
@GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(false, null))));
}
}

View File

@@ -1,32 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("api/v1/upload")
public class UploadController {
private final UploadService uploadService;
@Autowired
public UploadController(UploadService uploadService) {
this.uploadService = uploadService;
}
@PostMapping()
public ResponseEntity<APIResponse<Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Invalid file");
}
String uuid = uploadService.upload(file);
APIResponse<Object> response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid));
return new ResponseEntity<>(response, HttpStatus.OK);
}
}

View File

@@ -1,7 +1,7 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.dto.TokenDTO;
import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService;
@@ -14,18 +14,19 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional;
@RestController
@RequestMapping("/api/v1/auth/")
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@Value("${jwt.expiration}")
private long jwtExpiration;
private static final String SUCCESS = "success";
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user")
@GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser();
@@ -34,14 +35,14 @@ public class UserController {
}
return ResponseEntity.ok(
new APIResponse<>("success", "User retrieved successfully", user.get())
new APIResponse<>(SUCCESS, "User retrieved successfully", user.get())
);
}
@PostMapping("/login")
public ResponseEntity<APIResponse<TokenDTO>> login(@RequestBody TokenDTO tokenDTO,
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletResponse response) {
String jwt = userService.login(tokenDTO.getToken());
String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true)
@@ -54,7 +55,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(
new APIResponse<>("success", "Logged in successfully", new TokenDTO(jwt))
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
);
}
@@ -71,7 +72,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(
new APIResponse<>("success", "Logged out successfully", null)
new APIResponse<>(SUCCESS, "Logged out successfully", null)
);
}
}

View File

@@ -0,0 +1,12 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record ClipResponse(
Long id,
Long userId,
String title,
String description,
Float duration,
Instant createdAt
) {}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull;
public record ClipSectionRequest(
@NotNull float duration
) {}

View File

@@ -0,0 +1,6 @@
package com.ddf.vodsystem.controllers.dto;
public record ClipUpdateRequest (
String title,
String description
) {}

View File

@@ -0,0 +1,15 @@
package com.ddf.vodsystem.controllers.dto;
import com.ddf.vodsystem.dto.JobState;
import java.time.Instant;
public record JobResponse (
String uuid,
float progress,
boolean isComplete,
JobState state,
String errorOutput,
Instant createdAt
) {
}

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
import jakarta.validation.constraints.NotNull;
public record SaveSectionRequest(
@NotNull Instant startTime,
@NotNull Instant endTime) {}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
public record StreamResponse(
boolean isStreaming,
Long id
) {
}

View File

@@ -0,0 +1,5 @@
package com.ddf.vodsystem.controllers.dto;
public record Token(
String token
) {}

View File

@@ -0,0 +1,3 @@
package com.ddf.vodsystem.controllers.dto;
public record UUIDResponse(String uuid) {}

View File

@@ -1,15 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ClipDTO {
private Long id;
private Long userId;
private String title;
private String description;
private Float duration;
private LocalDateTime createdAt;
}

View File

@@ -1,15 +1,31 @@
package com.ddf.vodsystem.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data
public class ClipOptions {
@NotBlank
private String title;
private String description;
@NotNull
@Positive
private Float startPoint;
@NotNull @Positive
private Float duration;
@DecimalMin("1.0") @DecimalMax("120.0")
private Float fps;
@Positive
private Integer width;
@Positive
private Integer height;
@Positive
private Float fileSize;
}

View File

@@ -1,28 +1,21 @@
package com.ddf.vodsystem.dto;
import java.io.File;
import lombok.Data;
import java.nio.file.Path;
import java.time.Instant;
@Data
public class Job {
private String uuid;
private File inputFile;
private File outputFile;
private ProgressTracker progressTracker = new ProgressTracker();
private JobState state = JobState.READY;
private String errorOutput;
// configs
private ClipOptions inputClipOptions;
private ClipOptions outputClipOptions = new ClipOptions();
private Path download;
private Instant createdAt = Instant.now();
// job status
private JobStatus status = new JobStatus();
public Job(String uuid,
File inputFile,
File outputFile,
ClipOptions inputClipOptions) {
public Job(String uuid) {
this.uuid = uuid;
this.inputFile = inputFile;
this.outputFile = outputFile;
this.inputClipOptions = inputClipOptions;
}
}

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.dto;
public enum JobState {
READY,
PROCESSING,
SUCCEEDED,
FAILED
}

View File

@@ -1,12 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
@Data
public class JobStatus {
private ProgressTracker process = new ProgressTracker();
private ProgressTracker conversion = new ProgressTracker();
private boolean failed = false;
}

View File

@@ -1,10 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenDTO {
private String token;
}

View File

@@ -5,7 +5,7 @@ import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.time.Instant;
@Entity
@Table(name = "clips")
@@ -28,7 +28,7 @@ public class Clip {
private String description;
@Column(name = "created_at")
private LocalDateTime createdAt;
private Instant createdAt;
@Column(name = "width", nullable = false)
private Integer width;

Some files were not shown because too many files have changed in this diff Show More