diff --git a/.env.example b/.env.example index 315c280..6083238 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,18 @@ +# Secrets GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -JWT_SECRET_KEY= \ No newline at end of file +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= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 907f3e0..60a230e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ build/ .vscode/ ### Generated Files ### -videos/ +/videos +/outputs +frontend/.flowbite-react generated +.mvn diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index d58dfb7..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -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 diff --git a/bruno/VoD-System/Clips/Delete Clip.bru b/bruno/VoD-System/Clips/Delete Clip.bru new file mode 100644 index 0000000..e7583d6 --- /dev/null +++ b/bruno/VoD-System/Clips/Delete Clip.bru @@ -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 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru new file mode 100644 index 0000000..6e5def3 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru @@ -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 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru new file mode 100644 index 0000000..41f0f13 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru @@ -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 +} diff --git a/bruno/VoD-System/Clips/Downloads/folder.bru b/bruno/VoD-System/Clips/Downloads/folder.bru new file mode 100644 index 0000000..7b0de5c --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Downloads + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Clips/Get Clip.bru b/bruno/VoD-System/Clips/Get Clip.bru new file mode 100644 index 0000000..89a935e --- /dev/null +++ b/bruno/VoD-System/Clips/Get Clip.bru @@ -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 +} diff --git a/bruno/VoD-System/Clips/Get all clips.bru b/bruno/VoD-System/Clips/Get all clips.bru index d78388d..4d33b6b 100644 --- a/bruno/VoD-System/Clips/Get all clips.bru +++ b/bruno/VoD-System/Clips/Get all clips.bru @@ -1,7 +1,7 @@ meta { name: Get all clips type: http - seq: 1 + seq: 2 } get { diff --git a/bruno/VoD-System/Clips/Update Clip.bru b/bruno/VoD-System/Clips/Update Clip.bru new file mode 100644 index 0000000..7aa91b8 --- /dev/null +++ b/bruno/VoD-System/Clips/Update Clip.bru @@ -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 +} diff --git a/bruno/VoD-System/Clips/folder.bru b/bruno/VoD-System/Clips/folder.bru new file mode 100644 index 0000000..ad6dce9 --- /dev/null +++ b/bruno/VoD-System/Clips/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Clips + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Download/Converted Download.bru b/bruno/VoD-System/Download/Converted Download.bru deleted file mode 100644 index d68bf22..0000000 --- a/bruno/VoD-System/Download/Converted Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Download - type: http - seq: 2 -} - -get { - url: {{base_url}}/download/output/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/Original Download.bru b/bruno/VoD-System/Download/Original Download.bru deleted file mode 100644 index 91d8e26..0000000 --- a/bruno/VoD-System/Download/Original Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Download - type: http - seq: 1 -} - -get { - url: {{base_url}}/download/input/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/folder.bru b/bruno/VoD-System/Download/folder.bru deleted file mode 100644 index be111cc..0000000 --- a/bruno/VoD-System/Download/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Download -} diff --git a/bruno/VoD-System/Edit/Edit.bru b/bruno/VoD-System/Edit/Edit.bru deleted file mode 100644 index 63a5e17..0000000 --- a/bruno/VoD-System/Edit/Edit.bru +++ /dev/null @@ -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 -} diff --git a/bruno/VoD-System/Edit/Process.bru b/bruno/VoD-System/Edit/Process.bru deleted file mode 100644 index a44770d..0000000 --- a/bruno/VoD-System/Edit/Process.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Process - type: http - seq: 2 -} - -get { - url: {{base_url}}/process/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/Progress.bru b/bruno/VoD-System/Edit/Progress.bru deleted file mode 100644 index c54718f..0000000 --- a/bruno/VoD-System/Edit/Progress.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Progress - type: http - seq: 3 -} - -get { - url: {{base_url}}/progress/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/folder.bru b/bruno/VoD-System/Edit/folder.bru deleted file mode 100644 index 8dd7263..0000000 --- a/bruno/VoD-System/Edit/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Edit -} diff --git a/bruno/VoD-System/Jobs/Download Video.bru b/bruno/VoD-System/Jobs/Download Video.bru new file mode 100644 index 0000000..2179683 --- /dev/null +++ b/bruno/VoD-System/Jobs/Download Video.bru @@ -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 +} diff --git a/bruno/VoD-System/Jobs/Get Job.bru b/bruno/VoD-System/Jobs/Get Job.bru new file mode 100644 index 0000000..87ca912 --- /dev/null +++ b/bruno/VoD-System/Jobs/Get Job.bru @@ -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 +} diff --git a/bruno/VoD-System/Jobs/folder.bru b/bruno/VoD-System/Jobs/folder.bru new file mode 100644 index 0000000..8828051 --- /dev/null +++ b/bruno/VoD-System/Jobs/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Jobs + seq: 5 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Media/Clip Stream.bru b/bruno/VoD-System/Media/Clip Stream.bru new file mode 100644 index 0000000..1010702 --- /dev/null +++ b/bruno/VoD-System/Media/Clip Stream.bru @@ -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); +} diff --git a/bruno/VoD-System/Media/Compress.bru b/bruno/VoD-System/Media/Compress.bru new file mode 100644 index 0000000..0a1e443 --- /dev/null +++ b/bruno/VoD-System/Media/Compress.bru @@ -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); +} diff --git a/bruno/VoD-System/Media/Save Stream Section.bru b/bruno/VoD-System/Media/Save Stream Section.bru new file mode 100644 index 0000000..ef727ae --- /dev/null +++ b/bruno/VoD-System/Media/Save Stream Section.bru @@ -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); +} diff --git a/bruno/VoD-System/Media/folder.bru b/bruno/VoD-System/Media/folder.bru new file mode 100644 index 0000000..3c64875 --- /dev/null +++ b/bruno/VoD-System/Media/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Media + seq: 2 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Metadata/Converted Metadata.bru b/bruno/VoD-System/Metadata/Converted Metadata.bru deleted file mode 100644 index 0bda004..0000000 --- a/bruno/VoD-System/Metadata/Converted Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Metadata - type: http - seq: 2 -} - -get { - url: {{base_url}}/metadata/converted/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/Original Metadata.bru b/bruno/VoD-System/Metadata/Original Metadata.bru deleted file mode 100644 index 1a56e14..0000000 --- a/bruno/VoD-System/Metadata/Original Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Metadata - type: http - seq: 1 -} - -get { - url: {{base_url}}/metadata/original/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/folder.bru b/bruno/VoD-System/Metadata/folder.bru deleted file mode 100644 index b670ec8..0000000 --- a/bruno/VoD-System/Metadata/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Metadata -} diff --git a/bruno/VoD-System/Streams/History Streams.bru b/bruno/VoD-System/Streams/History Streams.bru new file mode 100644 index 0000000..3a00096 --- /dev/null +++ b/bruno/VoD-System/Streams/History Streams.bru @@ -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 +} diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru new file mode 100644 index 0000000..6af7cf9 --- /dev/null +++ b/bruno/VoD-System/Streams/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Streams + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Upload.bru b/bruno/VoD-System/Upload.bru deleted file mode 100644 index 7a6c424..0000000 --- a/bruno/VoD-System/Upload.bru +++ /dev/null @@ -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); -} diff --git a/bruno/VoD-System/Auth/Get Google Token.bru b/bruno/VoD-System/Users/Get Google Token.bru similarity index 100% rename from bruno/VoD-System/Auth/Get Google Token.bru rename to bruno/VoD-System/Users/Get Google Token.bru diff --git a/bruno/VoD-System/Auth/Get User.bru b/bruno/VoD-System/Users/Get User.bru similarity index 74% rename from bruno/VoD-System/Auth/Get User.bru rename to bruno/VoD-System/Users/Get User.bru index 28036c9..20c0ad5 100644 --- a/bruno/VoD-System/Auth/Get User.bru +++ b/bruno/VoD-System/Users/Get User.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{base_url}}/auth/user + url: {{base_url}}/users/me body: none auth: inherit } diff --git a/bruno/VoD-System/Auth/Login.bru b/bruno/VoD-System/Users/Login.bru similarity index 87% rename from bruno/VoD-System/Auth/Login.bru rename to bruno/VoD-System/Users/Login.bru index a317e24..781ebba 100644 --- a/bruno/VoD-System/Auth/Login.bru +++ b/bruno/VoD-System/Users/Login.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{base_url}}/auth/login + url: {{base_url}}/users/login body: json auth: none } diff --git a/bruno/VoD-System/Users/folder.bru b/bruno/VoD-System/Users/folder.bru new file mode 100644 index 0000000..babc4ab --- /dev/null +++ b/bruno/VoD-System/Users/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Users + seq: 3 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/collection.bru b/bruno/VoD-System/collection.bru new file mode 100644 index 0000000..66123ad --- /dev/null +++ b/bruno/VoD-System/collection.bru @@ -0,0 +1,7 @@ +auth { + mode: bearer +} + +auth:bearer { + token: {{token}} +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6cc3fbf --- /dev/null +++ b/docker-compose.dev.yml @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 896c343..7c498dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: \ No newline at end of file + 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 \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 0561dd7..f7b922a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . EXPOSE 5173 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c84db0..3dd46ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/Box.tsx b/frontend/src/components/Box.tsx index c0d857f..31a3872 100644 --- a/frontend/src/components/Box.tsx +++ b/frontend/src/components/Box.tsx @@ -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 ( -
- { children } -
- ) -} +const Box = ({ children, className }: Props) => ( +
+ {children} +
+); -export default Box; \ No newline at end of file +export default Box; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index 4be7738..db384e0 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -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 ( -
  • onClick(item)} - > - {item} -
  • - ); -} +const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( +
  • onClick(item)} + > + {item} +
  • +); 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(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 ( -
    +
    {isOpen && ( -
      +
        {children}
      )} @@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => { ); }; - - -export { Dropdown, DropdownItem }; \ No newline at end of file +export { Dropdown, DropdownItem }; diff --git a/frontend/src/components/Selector.tsx b/frontend/src/components/Selector.tsx index 7792c7a..8eab14f 100644 --- a/frontend/src/components/Selector.tsx +++ b/frontend/src/components/Selector.tsx @@ -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 ( -
      - -
      - {children} -
      - ) -} +const Selector = ({ children, label }: Props) => ( +
      + +
      + {children} +
      +); -export default Selector; \ No newline at end of file +export default Selector; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 4e6e272..ca409d6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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,35 +8,44 @@ type props = { className?: string } +const Divider = () =>
      ; + const Sidebar = ({user, className}: props) => { return ( - - {"VoD - } - label={"Home"} - /> - - } - label={"Create Clip"} - /> - - { user && ( - } - label={"My Clips"} + ); }; -export default Sidebar; \ No newline at end of file +export default Sidebar; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 9026186..d3bd7dd 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -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 ( -
      +
      setSidebarToggled(!sidebarToggled)}> {sidebarToggled ? : } @@ -69,7 +71,19 @@ const Topbar = ({ referrerPolicy="no-referrer" /> - + + {user.name} + {isStreaming && ( + + + + + )} + + }> + navigate("/profile")} /> handleLogout()} className={"text-red-500 font-medium"} /> diff --git a/frontend/src/components/buttons/BlueButton.tsx b/frontend/src/components/buttons/BlueButton.tsx index 42ac4ef..4dce83e 100644 --- a/frontend/src/components/buttons/BlueButton.tsx +++ b/frontend/src/components/buttons/BlueButton.tsx @@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes; const BlueButton: React.FC = ({ className = "", ...props }) => { return ( - - - +
      ); -} \ No newline at end of file +} diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index ffc2ab1..084aafc 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -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(null); - setTimeout(() => { - setTimeAgo(dateToTimeAgo(stringToDate(createdAt))) - }, 1000); + 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 ( -
      -
      +
      +
      Video Thumbnail -

      + {formatTime(duration)} -

      + + + {(onEdit || onDelete) && ( +
      e.preventDefault()} + > + + + {menuOpen && ( +
      + {!confirmDelete && (<> + {onEdit && ( + + )} + {onDelete && ( + + )} + )} + {confirmDelete && ( +
      +

      Delete this clip?

      +
      + + +
      +
      + )} +
      + )} +
      + )}
      - -
      -

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

      -

      - {timeAgo} +

      +

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

      +

      {timeAgo}

      diff --git a/frontend/src/index.css b/frontend/src/index.css index fc20e05..6c52b81 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; - line-height: 1.5; - font-weight: 400; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + 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); +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 3e42247..d21a82e 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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); + 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 (
      { sidebarToggled={sidebarToggled} setSidebarToggled={setSidebarToggled} user={user} - fetchUser={fetchUser}/> -
      + fetchUser={fetchUser} + isStreaming={isStreaming}/> +
      diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index f9b3fad..3ce8c99 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -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(null); - const videoUrl = `/api/v1/download/input/${id}` + const [videoUrl, setVideoUrl] = useState(''); + const [uploadedId, setUploadedId] = useState(null); + const [metadata, setMetadata] = useState(null); const [playbackValue, setPlaybackValue] = useState(0); const [outputMetadata, setOutputMetadata] = useState({ - // default values title: "", description: "", startPoint: 0, @@ -31,41 +36,66 @@ const ClipEdit = () => { const [downloadable, setDownloadable] = useState(false); const [error, setError] = useState(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}`)); + 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 interval = setInterval(async() => await pollProgress(id, interval), 500); - } + const interval = setInterval(async () => await pollProgress(jobId, interval), 500); + }; - const pollProgress = async (id: string, intervalId: number) => { - getProgress(id) - .then((progress) => { - setProgress(progress.process.progress); + const pollProgress = async (jobId: string, intervalId: number) => { + getJob(jobId) + .then((job) => { + setProgress(job.progress); - if (progress.process.complete) { + 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 ( -
      +
      - + ref={videoRef} + src={videoUrl} + className={"w-full rounded-lg shadow-sm bg-black"} + onLoadedMetadata={handleVideoMetadataLoaded} + /> - - + + - {metadata && {
      ); -} +}; -export default ClipEdit; \ No newline at end of file +export default ClipEdit; diff --git a/frontend/src/pages/ClipUpload.tsx b/frontend/src/pages/ClipUpload.tsx index d227f79..cf4cbb4 100644 --- a/frontend/src/pages/ClipUpload.tsx +++ b/frontend/src/pages/ClipUpload.tsx @@ -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(null); const navigate = useNavigate(); const [error, setError] = useState(null); - const [progress, setProgress] = useState(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 = { - 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 ( - - { - const selected = e.target.files?.[0] ?? null; - setFile(selected); - }} - className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"} - /> +
      + +

      Upload Clip

      - - Upload - + { + const selected = e.target.files?.[0] ?? null; + setFile(selected); + setError(null); + }} + className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150" + /> - - - -
      + Upload + + {error &&

      {error}

      } + +
      ) }; diff --git a/frontend/src/pages/EditClip.tsx b/frontend/src/pages/EditClip.tsx new file mode 100644 index 0000000..58d86b1 --- /dev/null +++ b/frontend/src/pages/EditClip.tsx @@ -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(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 ( +
      + + +

      Edit Clip

      + + {loading ? ( +
      Loading...
      + ) : ( + +
      + + setTitle(e.target.value)} + placeholder="Enter title" + className={inputClass} + /> +
      + +
      + +