feat(frontend): restyle and design frontend
This commit is contained in:
@@ -7,7 +7,7 @@ type props = {
|
|||||||
|
|
||||||
const Box = ({children, className}: props) => {
|
const Box = ({children, className}: props) => {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}>
|
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||||
{ children }
|
{ children }
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type DropdownItemProps = {
|
|||||||
|
|
||||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
|
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => {
|
||||||
return (
|
return (
|
||||||
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")}
|
<li className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||||
onClick={() => onClick(item)}
|
onClick={() => onClick(item)}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
@@ -49,12 +49,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
|||||||
ref={ref}>
|
ref={ref}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDropdown}
|
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"}
|
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}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{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}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ type props = {
|
|||||||
const Selector = ({children, label}: props) => {
|
const Selector = ({children, label}: props) => {
|
||||||
return (
|
return (
|
||||||
<div className={"flex items-center gap-2"}>
|
<div className={"flex items-center gap-2"}>
|
||||||
<label
|
<label className={"w-full text-sm text-gray-600"}>
|
||||||
className={"w-full"}>
|
|
||||||
{ label }
|
{ label }
|
||||||
</label>
|
</label>
|
||||||
<div className={"w-px h-6 bg-gray-400 mx-3"} />
|
<div className={"w-px h-5 bg-gray-200 mx-2"} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||||
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
|
||||||
import Box from "./Box.tsx";
|
|
||||||
import type {User} from "../utils/types.ts";
|
import type {User} from "../utils/types.ts";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -11,39 +10,25 @@ type props = {
|
|||||||
|
|
||||||
const Sidebar = ({user, className}: props) => {
|
const Sidebar = ({user, className}: props) => {
|
||||||
return (
|
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)}>
|
||||||
<img
|
<div className="px-4 pt-5 pb-3">
|
||||||
className={"w-45 mx-auto"}
|
<img
|
||||||
src={"../../public/logo.png"}
|
className="w-36 mx-auto"
|
||||||
alt={"VoD System Logo"}
|
src="../../public/logo.png"
|
||||||
/>
|
alt="VoD System Logo"
|
||||||
<SidebarButton
|
/>
|
||||||
url={"/"}
|
</div>
|
||||||
logo={<Home size={20}/>}
|
<nav className="flex flex-col flex-1 gap-0.5 px-2 pb-4">
|
||||||
label={"Home"}
|
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
|
||||||
/>
|
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
|
||||||
|
{user && (
|
||||||
<SidebarButton
|
<>
|
||||||
url={"/create"}
|
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="My Clips" />
|
||||||
logo={<Plus size={20}/>}
|
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||||
label={"Create Clip"}
|
</>
|
||||||
/>
|
|
||||||
|
|
||||||
{ user && (
|
|
||||||
<>
|
|
||||||
<SidebarButton
|
|
||||||
url={"/my-clips"}
|
|
||||||
logo={<Film size={20}/>}
|
|
||||||
label={"My Clips"}
|
|
||||||
/>
|
|
||||||
<SidebarButton
|
|
||||||
url={"/profile"}
|
|
||||||
logo={<UserIcon size={20}/>}
|
|
||||||
label={"Profile"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</nav>
|
||||||
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const Topbar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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)}>
|
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
|
||||||
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import MenuButton from "./MenuButton.tsx";
|
import MenuButton from "./MenuButton.tsx";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -8,12 +8,12 @@ type props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SidebarButton = ({url, logo, label}: props) => {
|
const SidebarButton = ({url, logo, label}: props) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link className="w-full" to={url}>
|
||||||
className={"w-full"}
|
<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"}`}>
|
||||||
to={url}
|
|
||||||
>
|
|
||||||
<MenuButton className={"flex items-center gap-2 w-full"}>
|
|
||||||
{logo}{label}
|
{logo}{label}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type prop = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||||
|
|
||||||
export default function ConfigBox({setMetadata, className}: prop) {
|
export default function ConfigBox({setMetadata, className}: prop) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const vals = e.target.value.split(",");
|
const vals = e.target.value.split(",");
|
||||||
@@ -32,15 +34,12 @@ export default function ConfigBox({setMetadata, className}: prop) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-2 p-10", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<h2 className={"text-xl font-bold"}>Export</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label={"Resolution"}>
|
<Selector label="Resolution">
|
||||||
<select id="resolution"
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
name="resolution"
|
onChange={updateRes} className={inputClass}>
|
||||||
defaultValue="1280,720"
|
|
||||||
onChange={updateRes}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
|
||||||
<option value="3840,2160">2160p</option>
|
<option value="3840,2160">2160p</option>
|
||||||
<option value="2560,1440">1440p</option>
|
<option value="2560,1440">1440p</option>
|
||||||
<option value="1920,1080">1080p</option>
|
<option value="1920,1080">1080p</option>
|
||||||
@@ -50,27 +49,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
|
|||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label={"FPS"}>
|
<Selector label="FPS">
|
||||||
<select id="fps"
|
<select id="fps" name="fps" defaultValue="30"
|
||||||
name="fps"
|
onChange={updateFps} className={inputClass}>
|
||||||
defaultValue="30"
|
|
||||||
onChange={updateFps}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
|
|
||||||
<option value="60">60</option>
|
<option value="60">60</option>
|
||||||
<option value="30">30</option>
|
<option value="30">30</option>
|
||||||
<option value="15">15</option>
|
<option value="15">15</option>
|
||||||
</select>
|
</select>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
<Selector label={"File Size Limit (MB)"}>
|
<Selector label="File Size Limit (MB)">
|
||||||
<input type="number"
|
<input type="number" min="1" defaultValue="10"
|
||||||
min="1"
|
onChange={updateFileSize} className={inputClass}
|
||||||
defaultValue="10"
|
|
||||||
onChange={updateFileSize}
|
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
|
||||||
/>
|
/>
|
||||||
</Selector>
|
</Selector>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -16,17 +16,19 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props)
|
|||||||
Export
|
Export
|
||||||
</BlueButton>
|
</BlueButton>
|
||||||
|
|
||||||
{ downloadable ?
|
<div className="h-10 flex items-center">
|
||||||
(<BlueButton
|
{ downloadable ?
|
||||||
onClick={handleDownload}>
|
(<BlueButton className="w-full" onClick={handleDownload}>
|
||||||
Download
|
Download
|
||||||
</BlueButton>)
|
</BlueButton>)
|
||||||
:(
|
:(
|
||||||
<progress
|
<progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className={"bg-gray-300 rounded-lg h-1"}>
|
className="w-full h-1.5 rounded bg-gray-200">
|
||||||
</progress> )
|
</progress>
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ type MetadataBoxProps = {
|
|||||||
|
|
||||||
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
|
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||||
|
|
||||||
<p className={"w-full font-bold text-xl "}>Title</p>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter title"
|
placeholder="Enter title"
|
||||||
@@ -19,7 +17,7 @@ const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
|||||||
...prevState,
|
...prevState,
|
||||||
title: e.target.value
|
title: e.target.value
|
||||||
}))}
|
}))}
|
||||||
className={"border-black bg-gray-200 rounded-md w-full p-2"}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
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} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,41 +40,24 @@ const VideoCard = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={"/video/" + id}>
|
<Link to={"/video/" + id}>
|
||||||
<div className={clsx("flex flex-col", className)}>
|
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||||
<div className={"relative inline-block"}>
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
|
src={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
|
||||||
alt="Video Thumbnail"
|
alt="Video Thumbnail"
|
||||||
|
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
|
<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">
|
||||||
<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
|
|
||||||
">
|
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col p-2">
|
||||||
|
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||||
<div className={"flex flex-col justify-between p-2"}>
|
{title === "" ? "(No Title)" : title}
|
||||||
<p>{title == "" ? "(No Title)" : title}</p>
|
|
||||||
<p
|
|
||||||
className={"text-gray-600 text-sm"}
|
|
||||||
>
|
|
||||||
{timeAgo}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,95 +2,44 @@
|
|||||||
@plugin "flowbite-react/plugin/tailwindcss";
|
@plugin "flowbite-react/plugin/tailwindcss";
|
||||||
@source "../.flowbite-react/class-list.json";
|
@source "../.flowbite-react/class-list.json";
|
||||||
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-display: "Satoshi", "sans-serif";
|
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
|
||||||
/* Breakpoints */
|
/* Breakpoints */
|
||||||
--breakpoint-3xl: 1920px;
|
--breakpoint-3xl: 1920px;
|
||||||
|
|
||||||
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */
|
--color-primary: oklch(0.55 0.21 254);
|
||||||
--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-pressed: oklch(0.45 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 */
|
/* Easing */
|
||||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||||
--ease-snappy: cubic-bezier(0.2, 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);
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#range-slider .range-slider__thumb{
|
#range-slider .range-slider__thumb {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Helvetica;
|
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
font-synthesis: none;
|
||||||
font-synthesis: none;
|
text-rendering: optimizeLegibility;
|
||||||
text-rendering: optimizeLegibility;
|
-webkit-font-smoothing: antialiased;
|
||||||
-webkit-font-smoothing: antialiased;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*a {*/
|
body {
|
||||||
/* font-weight: 500;*/
|
background-color: var(--color-background);
|
||||||
/* 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;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const MainLayout = () => {
|
|||||||
setSidebarToggled={setSidebarToggled}
|
setSidebarToggled={setSidebarToggled}
|
||||||
user={user}
|
user={user}
|
||||||
fetchUser={fetchUser}/>
|
fetchUser={fetchUser}/>
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto bg-background">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,10 +95,10 @@ const ClipEdit = () => {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"grid grid-cols-[7fr_3fr]"}>
|
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}>
|
className={"w-full rounded-lg shadow-sm bg-black"}>
|
||||||
<source src={videoUrl} type="video/mp4" />
|
<source src={videoUrl} type="video/mp4" />
|
||||||
<source src={videoUrl} type="video/webm" />
|
<source src={videoUrl} type="video/webm" />
|
||||||
<source src={videoUrl} type="video/ogg" />
|
<source src={videoUrl} type="video/ogg" />
|
||||||
|
|||||||
@@ -38,20 +38,16 @@ const ClipUpload = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then(uuid => {
|
.then(uuid => {
|
||||||
|
|
||||||
if (isVideoFileSupported(file)) {
|
if (isVideoFileSupported(file)) {
|
||||||
navigate(`/create/${uuid}`)
|
navigate(`/create/${uuid}`)
|
||||||
} else {
|
} else {
|
||||||
convertFile(uuid);
|
convertFile(uuid);
|
||||||
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
|
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
|
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const pollProgress = async (id: string, intervalId: number) => {
|
const pollProgress = async (id: string, intervalId: number) => {
|
||||||
@@ -71,27 +67,27 @@ const ClipUpload = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={"flex flex-col justify-between gap-3 p-5"}>
|
<div className="flex items-start justify-center p-8 min-h-full">
|
||||||
<input
|
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||||
type="file"
|
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||||
onChange={(e) => {
|
|
||||||
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"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BlueButton
|
<input
|
||||||
onClick={press}>
|
type="file"
|
||||||
Upload
|
onChange={(e) => {
|
||||||
</BlueButton>
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
<label className={"text-center text-red-500"}>{error}</label>
|
<BlueButton onClick={press}>Upload</BlueButton>
|
||||||
<progress
|
|
||||||
value={progress}
|
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||||
className={"bg-gray-300 rounded-lg h-1"}>
|
|
||||||
</progress>
|
<progress value={progress} className="w-full h-1 rounded bg-gray-200" />
|
||||||
</Box>
|
</Box>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}>
|
<div className="flex flex-col items-center justify-center px-6 py-12 min-h-full">
|
||||||
{/* Logo */}
|
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="VoD System Logo"
|
alt="VoD System Logo"
|
||||||
className={"h-44 mb-8 object-contain"}
|
className="h-36 mb-8 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Description Container */}
|
<div className="max-w-xl text-center">
|
||||||
<div className={"max-w-xl text-center"}>
|
<h2 className="text-3xl font-semibold mb-4 text-gray-900">
|
||||||
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}>
|
|
||||||
What is the VoD System?
|
What is the VoD System?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
|
<p className="text-base leading-relaxed text-gray-600 mb-6">
|
||||||
The VoD System is a powerful clip management platform designed to streamline how you
|
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,
|
handle your video content. Whether you're a content creator, streamer, or educator,
|
||||||
VoD System lets you:
|
VoD System lets you:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
|
<ul className="list-disc list-inside text-gray-600 mb-8 space-y-2 text-left">
|
||||||
<li>Upload clips effortlessly and securely.</li>
|
<li>Upload clips effortlessly and securely.</li>
|
||||||
<li>Edit and trim videos with intuitive controls.</li>
|
<li>Edit and trim videos with intuitive controls.</li>
|
||||||
<li>Compress files to specific file sizes.</li>
|
<li>Compress files to specific file sizes.</li>
|
||||||
<li>Organize your clips for quick access and sharing.</li>
|
<li>Organize your clips for quick access and sharing.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className={"text-lg leading-relaxed text-gray-600"}>
|
<p className="text-base leading-relaxed text-gray-600 mb-8">
|
||||||
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
|
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
|
||||||
making video clip management faster and more enjoyable than ever.
|
making video clip management faster and more enjoyable than ever.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Support Note */}
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700 max-w-md mx-auto">
|
||||||
<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{" "}
|
||||||
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing.
|
<code className="font-mono bg-blue-100 px-1 rounded">.mp4</code> files are supported
|
||||||
Support for additional video formats will be added in future updates.
|
for upload and processing. Support for additional video formats will be added in future updates.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,18 +14,29 @@ const MyClips = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-row"}>
|
<div className="p-6">
|
||||||
{clips.map((clip) => (
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||||
<VideoCard
|
|
||||||
id={clip.id}
|
|
||||||
title={clip.title}
|
|
||||||
duration={clip.duration}
|
|
||||||
createdAt={clip.createdAt}
|
|
||||||
className={"w-40 m-5"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{error}
|
<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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-gray-500 text-lg">
|
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||||
Please log in to view your profile.
|
Please log in to view your profile.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -30,17 +30,17 @@ const Profile = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<h1 className="text-2xl font-semibold text-gray-700 mb-6">Profile</h1>
|
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||||
|
|
||||||
<Box className="p-6 mb-6 flex items-center gap-6">
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
src={user.profilePictureUrl}
|
src={user.profilePictureUrl}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
alt="Profile picture"
|
alt="Profile picture"
|
||||||
className="w-20 h-20 rounded-full shadow"
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl font-semibold text-gray-800">{user.name}</p>
|
<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.username}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
@@ -50,31 +50,31 @@ const Profile = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6">
|
<Box className="p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-700 mb-1">Stream Key</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
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.
|
Keep it private — anyone with this key can stream to your account.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-gray-100 border border-gray-300 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
<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)}
|
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setKeyVisible(v => !v)}
|
onClick={() => setKeyVisible(v => !v)}
|
||||||
title={keyVisible ? "Hide key" : "Show key"}
|
title={keyVisible ? "Hide key" : "Show key"}
|
||||||
className="p-2 rounded-md bg-gray-300 hover:bg-gray-400 transition-colors text-gray-700"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{keyVisible ? <EyeOff size={18} /> : <Eye size={18} />}
|
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy key"
|
title="Copy key"
|
||||||
className="p-2 rounded-md bg-gray-300 hover:bg-gray-400 transition-colors text-gray-700"
|
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={18} className="text-green-600" /> : <Copy size={18} />}
|
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useParams} from "react-router-dom";
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import type {Clip} from "../utils/types";
|
import type {Clip} from "../utils/types";
|
||||||
import {getClipById, getVideoBlob } from "../utils/endpoints.ts";
|
import {getClipById, getVideoBlob} from "../utils/endpoints.ts";
|
||||||
import Box from "../components/Box.tsx"
|
import Box from "../components/Box.tsx"
|
||||||
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
|
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
|
||||||
|
|
||||||
const VideoPlayer = () => {
|
const VideoPlayer = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [clip, setClip] = useState<Clip | null>(null);
|
const [clip, setClip] = useState<Clip | null>(null);
|
||||||
@@ -24,7 +26,6 @@ const VideoPlayer = () => {
|
|||||||
setVideoUrl(url);
|
setVideoUrl(url);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
getClipById(id)
|
getClipById(id)
|
||||||
.then((fetchedClip) => {setClip(fetchedClip)})
|
.then((fetchedClip) => {setClip(fetchedClip)})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -34,7 +35,6 @@ const VideoPlayer = () => {
|
|||||||
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Update timeAgo live every second
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clip || !clip.createdAt) return;
|
if (!clip || !clip.createdAt) return;
|
||||||
|
|
||||||
@@ -43,16 +43,24 @@ const VideoPlayer = () => {
|
|||||||
setTimeAgo(dateToTimeAgo(date));
|
setTimeAgo(dateToTimeAgo(date));
|
||||||
};
|
};
|
||||||
|
|
||||||
update(); // initial update
|
update();
|
||||||
const interval = setInterval(update, 1000);
|
const interval = setInterval(update, 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval); // cleanup
|
return () => clearInterval(interval);
|
||||||
}, [clip]);
|
}, [clip]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"w-9/10 m-auto"}>
|
<div className="p-6 max-w-5xl mx-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-4 transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
<video
|
<video
|
||||||
className={"w-full h-full rounded-lg m-auto"}
|
className="w-full rounded-xl shadow-md bg-black"
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
@@ -63,14 +71,13 @@ const VideoPlayer = () => {
|
|||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
{error && <div className="text-red-500 mt-2">{error}</div>}
|
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
||||||
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
|
{!videoUrl && !error && <div className="text-gray-400 text-sm mt-3">Loading video...</div>}
|
||||||
|
|
||||||
<Box className={"p-2 m-2 flex flex-col"}>
|
<Box className="p-4 mt-4">
|
||||||
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
|
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
|
||||||
<p>{timeAgo}</p>
|
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user