feat(frontend): restyle and design frontend

This commit is contained in:
2026-06-20 12:52:51 +01:00
parent 1062f72661
commit 91c3259331
21 changed files with 185 additions and 266 deletions

View File

@@ -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>
) )

View File

@@ -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>
)} )}
@@ -64,4 +64,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
export { Dropdown, DropdownItem }; export { Dropdown, DropdownItem };

View File

@@ -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>
) )

View File

@@ -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,40 +10,26 @@ 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>
); );
}; };
export default Sidebar; export default Sidebar;

View File

@@ -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>

View File

@@ -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}
/> />
); );

View File

@@ -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}
/> />
); );

View File

@@ -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,16 +8,16 @@ 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>
) )
} }
export default SidebarButton; export default SidebarButton;

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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,10 +17,10 @@ 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>
) )
} }
export default MetadataBox; export default MetadataBox;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;*/
/* }*/
/*}*/

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>
) )
}; };

View File

@@ -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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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,16 +71,15 @@ 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>
); );
}; };
export default VideoPlayer; export default VideoPlayer;