all other page restyling
This commit is contained in:
@@ -7,8 +7,8 @@ type Props = {
|
|||||||
|
|
||||||
const Selector = ({ children, label }: Props) => (
|
const Selector = ({ children, label }: Props) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="w-full text-sm text-gray-600">{label}</label>
|
<label className="w-full text-sm text-text-secondary">{label}</label>
|
||||||
<div className="w-px h-5 bg-gray-200 mx-2" />
|
<div className="w-px h-5 text-text-secondary mx-2" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ const Topbar = ({
|
|||||||
{user.name}
|
{user.name}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#B5432E] opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#B5432E]" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -86,7 +86,7 @@ const Topbar = ({
|
|||||||
onClick={() => navigate("/profile")} />
|
onClick={() => navigate("/profile")} />
|
||||||
<DropdownItem item="Logout"
|
<DropdownItem item="Logout"
|
||||||
onClick={() => handleLogout()}
|
onClick={() => handleLogout()}
|
||||||
className={"text-red-500 font-medium"} />
|
className={"text-[#B5432E] font-medium"} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
) :
|
) :
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BlueButton;
|
|
||||||
@@ -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-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
|
className={clsx("p-2 rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors duration-150", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal file
14
frontend/src/components/buttons/PrimaryButton.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
|
const PrimaryButton: React.FC<Props> = ({ className = "", ...props }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`bg-[#A85A38] text-[#FDFBF7] font-body text-sm rounded-lg hover:bg-[#94492A] disabled:bg-[#C9AE9C] transition-colors duration-150 h-10 px-4 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrimaryButton;
|
||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
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";
|
const inputClass = "border border-hairline bg-card rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
|
|
||||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
@@ -26,7 +26,7 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
<h2 className="text-sm text-text-primary font-semibold mb-1">Export Settings</h2>
|
||||||
|
|
||||||
<Selector label="Resolution">
|
<Selector label="Resolution">
|
||||||
<select id="resolution" name="resolution" defaultValue="1280,720"
|
<select id="resolution" name="resolution" defaultValue="1280,720"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlueButton from "../buttons/BlueButton.tsx";
|
import PrimaryButton from "../buttons/PrimaryButton.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
@@ -24,24 +24,24 @@ const Spinner = () => (
|
|||||||
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col gap-3"}>
|
<div className={"flex flex-col gap-3"}>
|
||||||
<BlueButton
|
<PrimaryButton
|
||||||
onClick={dataSend}
|
onClick={dataSend}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||||
>
|
>
|
||||||
{uploading && <Spinner />}
|
{uploading && <Spinner />}
|
||||||
{uploading ? "Uploading..." : "Export"}
|
{uploading ? "Uploading..." : "Export"}
|
||||||
</BlueButton>
|
</PrimaryButton>
|
||||||
|
|
||||||
<div className="h-10 flex items-center">
|
<div className="h-10 flex items-center">
|
||||||
{ downloadable ?
|
{ downloadable ?
|
||||||
(<BlueButton className="w-full" onClick={handleDownload}>
|
(<PrimaryButton className="w-full" onClick={handleDownload}>
|
||||||
Download
|
Download
|
||||||
</BlueButton>)
|
</PrimaryButton>)
|
||||||
:(
|
:(
|
||||||
<progress
|
<progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className="w-full h-1.5 rounded bg-gray-200">
|
className="w-full h-1.5 rounded bg-fields">
|
||||||
</progress>
|
</progress>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ type Props = {
|
|||||||
|
|
||||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||||
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
<p className="w-full font-body text-text-strong font-bold text-sm">Title</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter title"
|
placeholder="Enter title"
|
||||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||||
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
className="border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
className="hover:bg-hover rounded-full p-1.5 transition-colors duration-150 text-text-body"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
</button>
|
</button>
|
||||||
<span>
|
<span className="font-data text-sm text-text-secondary tabular-nums">
|
||||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
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">
|
<span className="absolute top-1.5 left-1.5 bg-[#2B2620]/75 text-[#FBF8F2] px-1.5 py-0.5 rounded text-xs font-data pointer-events-none">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -82,18 +82,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||||
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
|
className="bg-[#2B2620]/75 hover:bg-[#2B2620]/90 text-[#FBF8F2] rounded p-0.5 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreVertical size={14} />
|
<MoreVertical size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
<div className="absolute right-0 top-7 w-32 bg-card rounded-lg border border-hairline shadow-md z-50 py-1 overflow-hidden">
|
||||||
{!confirmDelete && (<>
|
{!confirmDelete && (<>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onEdit)}
|
onClick={(e) => stopAndRun(e, onEdit)}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-text-body hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Pencil size={13} /> Edit
|
<Pencil size={13} /> Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +101,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-[#B5432E] hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} /> Delete
|
<Trash2 size={13} /> Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -109,17 +109,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</>)}
|
</>)}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p>
|
<p className="text-xs text-text-secondary mb-2">Delete this {itemLabel}?</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||||
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
className="flex-1 text-xs bg-[#B5432E] text-[#FDFBF7] rounded px-2 py-1 hover:bg-[#94492A] transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||||
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
className="flex-1 text-xs bg-fields text-text-secondary rounded px-2 py-1 hover:bg-hover transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -133,10 +133,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col p-2">
|
<div className="flex flex-col p-2">
|
||||||
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
<p className="font-body text-text-strong text-sm leading-snug line-clamp-2">
|
||||||
{title === "" ? "(No Title)" : title}
|
{title === "" ? "(No Title)" : title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
|
<p className="text-muted text-xs mt-0.5">{timeAgo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -44,4 +44,27 @@ body {
|
|||||||
font-family: "Newsreader", serif;
|
font-family: "Newsreader", serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #2B2620;
|
color: #2B2620;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clip trim slider (react-range-slider-input). Its own stylesheet ships blue
|
||||||
|
defaults; the #id beats those class selectors regardless of import order. */
|
||||||
|
#range-slider {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--color-fields);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#range-slider .range-slider__range {
|
||||||
|
background: #C87A52;
|
||||||
|
}
|
||||||
|
|
||||||
|
#range-slider .range-slider__thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #A85A38;
|
||||||
|
box-shadow: 0 1px 2px rgba(90, 40, 20, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#range-slider .range-slider__thumb:focus-visible {
|
||||||
|
box-shadow: 0 0 0 4px rgba(168, 90, 56, 0.35);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ const ClipEdit = () => {
|
|||||||
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollProgress = async (jobId: string, intervalId: number) => {
|
const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
|
||||||
getJob(jobId)
|
getJob(jobId)
|
||||||
.then((job) => {
|
.then((job) => {
|
||||||
setProgress(job.progress);
|
setProgress(job.progress);
|
||||||
@@ -128,7 +128,7 @@ const ClipEdit = () => {
|
|||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
className={"w-full rounded-lg shadow-sm bg-black"}
|
className={"w-full rounded-lg shadow-sm bg-text-primary"}
|
||||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ const ClipEdit = () => {
|
|||||||
<Playbar
|
<Playbar
|
||||||
video={videoRef.current}
|
video={videoRef.current}
|
||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
className={"w-full accent-primary"}
|
className={"w-full accent-[#A85A38]"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PlaybackSlider
|
<PlaybackSlider
|
||||||
@@ -150,7 +150,7 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
sliderValue={playbackValue}
|
sliderValue={playbackValue}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
className={"w-full accent-primary"}
|
className={"w-full accent-[#A85A38]"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClipRangeSlider
|
<ClipRangeSlider
|
||||||
@@ -158,11 +158,11 @@ const ClipEdit = () => {
|
|||||||
videoMetadata={metadata}
|
videoMetadata={metadata}
|
||||||
setSliderValue={setPlaybackValue}
|
setSliderValue={setPlaybackValue}
|
||||||
setMetadata={setOutputMetadata}
|
setMetadata={setOutputMetadata}
|
||||||
className={"w-full mt-2 bg-primary"}
|
className={"w-full mt-2"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={"text-red-600 text-center mt-2"}>
|
<div className={"text-[#B5432E] text-center mt-2"}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
import { getClipById, patchClip } from "../utils/api/clips.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.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 inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
|
|
||||||
const EditClip = () => {
|
const EditClip = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -50,20 +50,20 @@ const EditClip = () => {
|
|||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit Clip</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-gray-400 text-sm">Loading...</div>
|
<div className="text-muted text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
<label className="text-sm font-medium text-text-secondary">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -74,7 +74,7 @@ const EditClip = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
<label className="text-sm font-medium text-text-secondary">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -84,17 +84,17 @@ const EditClip = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-[#B5432E]">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
: "Save"}
|
: "Save"}
|
||||||
</BlueButton>
|
</PrimaryButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ArrowLeft, Check } from "lucide-react";
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
import { getVodById, patchVod } from "../utils/api/vods.ts";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.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 inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
|
|
||||||
const EditVod = () => {
|
const EditVod = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -50,20 +50,20 @@ const EditVod = () => {
|
|||||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary mb-6 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Edit VoD</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-gray-400 text-sm">Loading...</div>
|
<div className="text-muted text-sm">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<Box className="p-6 flex flex-col gap-4">
|
<Box className="p-6 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
<label className="text-sm font-medium text-text-secondary">Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
@@ -74,7 +74,7 @@ const EditVod = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
<label className="text-sm font-medium text-text-secondary">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -84,17 +84,17 @@ const EditVod = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
{error && <p className="text-sm text-[#B5432E]">{error}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
<PrimaryButton onClick={handleSave} disabled={saving} className="w-28">
|
||||||
{saving ? "Saving…" : saved
|
{saving ? "Saving…" : saved
|
||||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||||
: "Save"}
|
: "Save"}
|
||||||
</BlueButton>
|
</PrimaryButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
className="text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const Home = () => {
|
|||||||
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
className="bg-card border border-hairline rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0">
|
||||||
<Icon size={18} className="text-primary" />
|
<Icon size={18} className="text-[#A85A38]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
<p className="font-body text-text-strong font-bold text-sm mb-1">{title}</p>
|
||||||
|
|||||||
@@ -202,16 +202,16 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
if (notAuthenticated) {
|
if (notAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
<div className="w-14 h-14 rounded-full bg-fields flex items-center justify-center">
|
||||||
<Lock size={24} className="text-gray-400" />
|
<Lock size={24} className="text-muted" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
<p className="text-2xl font-heading text-text-primary">Not authenticated</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
<p className="text-sm text-text-secondary mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Go back
|
Go back
|
||||||
@@ -224,7 +224,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
<div className="h-full flex flex-col p-4 gap-3">
|
<div className="h-full flex flex-col p-4 gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
|
className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary flex-shrink-0 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
Back
|
Back
|
||||||
@@ -233,7 +233,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Player */}
|
{/* Player */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
className="relative bg-[#2B2620] rounded-xl overflow-hidden flex-1 min-h-0 select-none"
|
||||||
onMouseMove={showControls}
|
onMouseMove={showControls}
|
||||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
@@ -265,7 +265,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Buffering spinner */}
|
{/* Buffering spinner */}
|
||||||
{isWaiting && mediaUrl && (
|
{isWaiting && mediaUrl && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
|
<div className="w-11 h-11 rounded-full border-[3px] border-[#FBF8F2]/20 border-t-[#FBF8F2] animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -282,20 +282,20 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
{/* Scrub bar */}
|
{/* Scrub bar */}
|
||||||
<div
|
<div
|
||||||
ref={progressRef}
|
ref={progressRef}
|
||||||
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
className="relative h-1 rounded-full bg-[#FBF8F2]/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
|
||||||
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-[#FBF8F2]/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
|
||||||
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
|
<div className="absolute inset-y-0 left-0 bg-[#C87A52] rounded-full" style={{ width: `${progressPct}%` }} />
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-[#FBF8F2] rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
|
||||||
style={{ left: `calc(${progressPct}% - 6px)` }}
|
style={{ left: `calc(${progressPct}% - 6px)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons row */}
|
{/* Buttons row */}
|
||||||
<div className="flex items-center gap-3 text-white">
|
<div className="flex items-center gap-3 text-[#FBF8F2]">
|
||||||
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
|
<button onClick={togglePlay} className="hover:text-[#FBF8F2]/70 transition-colors">
|
||||||
{isPlaying
|
{isPlaying
|
||||||
? <Pause size={20} fill="currentColor" />
|
? <Pause size={20} fill="currentColor" />
|
||||||
: <Play size={20} fill="currentColor" />}
|
: <Play size={20} fill="currentColor" />}
|
||||||
@@ -303,7 +303,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
|
|
||||||
{/* Volume — slider expands on hover */}
|
{/* Volume — slider expands on hover */}
|
||||||
<div className="flex items-center gap-1.5 group/vol">
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
|
<button onClick={toggleMute} className="hover:text-[#FBF8F2]/70 transition-colors">
|
||||||
<VolumeIcon size={18} />
|
<VolumeIcon size={18} />
|
||||||
</button>
|
</button>
|
||||||
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
|
||||||
@@ -312,18 +312,18 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
min={0} max={1} step={0.02}
|
min={0} max={1} step={0.02}
|
||||||
value={effectiveVolume}
|
value={effectiveVolume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
className="w-16 accent-white cursor-pointer"
|
className="w-16 accent-[#FBF8F2] cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs font-mono tabular-nums text-white/80">
|
<span className="text-xs font-data tabular-nums text-[#FBF8F2]/80">
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
|
<button onClick={toggleFullscreen} className="hover:text-[#FBF8F2]/70 transition-colors">
|
||||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,17 +331,17 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
|
{error && <div className="text-[#B5432E] text-sm mt-3">{error}</div>}
|
||||||
{!mediaLoaded && !error && (
|
{!mediaLoaded && !error && (
|
||||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
<div className="text-muted text-sm mt-3 flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
<div className="w-3 h-3 rounded-full border-2 border-[#DED7CA] border-t-muted animate-spin" />
|
||||||
Loading video...
|
Loading video...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box className="p-4 flex-shrink-0">
|
<Box className="p-4 flex-shrink-0">
|
||||||
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
|
<p className="text-2xl font-heading text-text-primary">{item?.title || "(No Title)"}</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
<p className="text-sm text-text-secondary mt-1">{timeAgo}</p>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const MyClips = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{clips.map((clip) => (
|
{clips.map((clip) => (
|
||||||
@@ -43,13 +43,13 @@ const MyClips = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clips.length === 0 && !error && (
|
{clips.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||||
<p className="text-base">No clips yet.</p>
|
<p className="text-base">No clips yet.</p>
|
||||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
|
{error && <p className="text-sm text-[#B5432E] mt-4">{error.toString()}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const MyVods = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
|
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{vods.map((vod) => (
|
{vods.map((vod) => (
|
||||||
@@ -46,13 +46,13 @@ const MyVods = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vods.length === 0 && !error && (
|
{vods.length === 0 && !error && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||||
<p className="text-base">No VoDs yet.</p>
|
<p className="text-base">No VoDs yet.</p>
|
||||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-500 mt-4">{error}</p>}
|
{error && <p className="text-sm text-[#B5432E] mt-4">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
<div className="flex justify-center items-center h-full text-muted text-base">
|
||||||
Please log in to view your profile.
|
Please log in to view your profile.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -31,7 +31,7 @@ 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-900 mb-6">Profile</h1>
|
<h1 className="text-4xl font-heading text-text-primary mb-6">Profile</h1>
|
||||||
|
|
||||||
<Box className="p-6 mb-4 flex items-center gap-5">
|
<Box className="p-6 mb-4 flex items-center gap-5">
|
||||||
<img
|
<img
|
||||||
@@ -41,31 +41,31 @@ const Profile = () => {
|
|||||||
className="w-20 h-20 rounded-full shadow-sm"
|
className="w-20 h-20 rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
<p className="text-2xl font-heading text-text-primary">{user.name}</p>
|
||||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
<p className="text-sm text-text-secondary">@{user.username}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="text-sm text-text-secondary">{user.email}</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-muted mt-1">
|
||||||
Member since {formatLocalDate(user.createdAt)}
|
Member since {formatLocalDate(user.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6">
|
<Box className="p-6">
|
||||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
<h2 className="text-xl font-heading text-text-primary mb-1">Stream Key</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-text-secondary 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-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
|
<div className="flex-1 bg-fields border border-hairline rounded-md px-4 py-2 font-data text-sm text-text-strong 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-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
||||||
>
|
>
|
||||||
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -73,9 +73,9 @@ const Profile = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy key"
|
title="Copy key"
|
||||||
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
|
className="p-2 rounded-md bg-fields hover:bg-hover transition-colors duration-150 text-text-secondary"
|
||||||
>
|
>
|
||||||
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
|
{copied ? <Check size={16} className="text-[#6E7A50]" /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Box from "../components/Box.tsx";
|
import Box from "../components/Box.tsx";
|
||||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
import PrimaryButton from "../components/buttons/PrimaryButton.tsx";
|
||||||
import { getUser } from "../utils/api/users.ts";
|
import { getUser } from "../utils/api/users.ts";
|
||||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||||
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
|
||||||
@@ -11,8 +11,8 @@ import { getJob } from "../utils/api/jobs.ts";
|
|||||||
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||||
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
||||||
|
|
||||||
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 inputClass = "border border-hairline bg-fields rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-muted transition-colors";
|
||||||
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
|
const labelClass = "font-data text-[11px] text-muted uppercase tracking-[0.12em]";
|
||||||
|
|
||||||
type TrackedJob = {
|
type TrackedJob = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -24,10 +24,10 @@ type TrackedJob = {
|
|||||||
|
|
||||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}>
|
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-[#A85A38]/10" : "bg-[#6E7A50]/10")}>
|
||||||
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
|
<Icon size={16} className={accent === "primary" ? "text-[#A85A38]" : "text-[#6E7A50]"} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
<h2 className="text-xl font-heading text-text-primary">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,25 +35,25 @@ const JobStatus = ({ job }: { job: TrackedJob }) => {
|
|||||||
if (job.state === 'FAILED') {
|
if (job.state === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
|
<X size={15} className="text-[#B5432E] mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
<p className="text-[#B5432E]">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (job.state === 'SUCCEEDED') {
|
if (job.state === 'SUCCEEDED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Check size={15} className="text-green-600 flex-shrink-0" />
|
<Check size={15} className="text-[#6E7A50] flex-shrink-0" />
|
||||||
<p className="text-gray-700">{job.label} saved</p>
|
<p className="text-text-body">{job.label} saved</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
|
<Loader2 size={15} className="text-[#A85A38] animate-spin flex-shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
|
<p className="text-sm text-text-secondary mb-1">{job.label}…</p>
|
||||||
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
|
<progress value={job.progress} className="w-full h-1.5 rounded bg-fields" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -226,7 +226,7 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
<div className="flex justify-center items-center h-full text-muted text-base">
|
||||||
Please log in to control your stream.
|
Please log in to control your stream.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -241,35 +241,35 @@ const StreamControl = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
|
<h1 className="text-4xl font-heading text-text-primary">Stream Control</h1>
|
||||||
|
|
||||||
{/* Hero status bar */}
|
{/* Hero status bar */}
|
||||||
<Box className={clsx(
|
<Box className={clsx(
|
||||||
"p-6 flex items-center justify-between gap-6 border-l-4",
|
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||||
isStreaming ? "border-l-red-500" : "border-l-gray-200"
|
isStreaming ? "border-l-[#B5432E]" : "border-l-hairline"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-3.5">
|
<div className="flex items-center gap-3.5">
|
||||||
<span className="relative flex h-3.5 w-3.5">
|
<span className="relative flex h-3.5 w-3.5">
|
||||||
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
|
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#B5432E] opacity-75" />}
|
||||||
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
|
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-[#B5432E]" : "bg-[#C4BAA9]")} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
<p className="text-2xl font-heading text-text-primary leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||||
{isStreaming && streamDetails?.startDate ? (
|
{isStreaming && streamDetails?.startDate ? (
|
||||||
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
|
<p className="text-sm text-text-secondary">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||||
) : !isStreaming ? (
|
) : !isStreaming ? (
|
||||||
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
|
<p className="text-sm text-text-secondary">Point OBS at your stream key (see Profile) to go live.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isStreaming && elapsed ? (
|
{isStreaming && elapsed ? (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
|
<p className="text-2xl font-data text-text-primary tabular-nums">{elapsed}</p>
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
|
<p className={labelClass}>elapsed</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Radio size={28} className="text-gray-200" />
|
<Radio size={28} className="text-[#DED7CA]" />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ const StreamControl = () => {
|
|||||||
{/* Quick mark toolbar */}
|
{/* Quick mark toolbar */}
|
||||||
<Box className="p-4 flex flex-col gap-3">
|
<Box className="p-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Bookmark size={18} className="text-accent flex-shrink-0" />
|
<Bookmark size={18} className="text-[#6E7A50] flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={markerMessage}
|
value={markerMessage}
|
||||||
@@ -287,11 +287,11 @@ const StreamControl = () => {
|
|||||||
placeholder="What just happened?"
|
placeholder="What just happened?"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
<PrimaryButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||||
Add Marker
|
Add Marker
|
||||||
</BlueButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
{markerError && <p className="text-sm text-[#B5432E]">{markerError}</p>}
|
||||||
|
|
||||||
{markers.length > 0 && (
|
{markers.length > 0 && (
|
||||||
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||||
@@ -304,22 +304,22 @@ const StreamControl = () => {
|
|||||||
onClick={() => handleChipClick(m.id)}
|
onClick={() => handleChipClick(m.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||||
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
|
isStart && "border-[#A85A38] bg-[#F6EFE6] ring-1 ring-[#A85A38]",
|
||||||
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
|
isEnd && "border-[#6E7A50] bg-[#F6EFE6] ring-1 ring-[#6E7A50]",
|
||||||
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
!isStart && !isEnd && "border-hairline hover:border-[#E2DACB] hover:bg-hover"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span>
|
<span className="text-xs font-medium text-text-strong max-w-40 truncate">{m.message}</span>
|
||||||
<span className="text-[11px] text-gray-400">
|
<span className="font-data text-[11px] text-muted">
|
||||||
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
{isStart && <span className="text-primary font-medium"> · start</span>}
|
{isStart && <span className="text-[#A85A38] font-medium"> · start</span>}
|
||||||
{isEnd && <span className="text-accent font-medium"> · end</span>}
|
{isEnd && <span className="text-[#6E7A50] font-medium"> · end</span>}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||||
title="Delete marker"
|
title="Delete marker"
|
||||||
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150"
|
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-muted hover:bg-[#B5432E] text-[#FDFBF7] transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
@@ -346,44 +346,44 @@ const StreamControl = () => {
|
|||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
|
||||||
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
|
{clipError && <p className="text-sm text-[#B5432E]">{clipError}</p>}
|
||||||
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
|
<PrimaryButton onClick={handleClip} className="self-start mt-auto">Clip</PrimaryButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="p-6 flex flex-col gap-3">
|
<Box className="p-6 flex flex-col gap-3">
|
||||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||||
{markers.length < 2 ? (
|
{markers.length < 2 ? (
|
||||||
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
|
<p className="text-sm text-muted">Add at least two markers above to save a section between them.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400"
|
startMarker ? "border-[#A85A38] text-[#A85A38] bg-[#F6EFE6]" : "border-dashed border-[#DED7CA] text-muted"
|
||||||
)}>
|
)}>
|
||||||
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
{startMarker ? startMarker.message : "Tap a marker to set start"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-300">→</span>
|
<span className="text-[#C4BAA9]">→</span>
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||||
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
|
endMarker ? "border-[#6E7A50] text-[#6E7A50] bg-[#F6EFE6]" : "border-dashed border-[#DED7CA] text-muted"
|
||||||
)}>
|
)}>
|
||||||
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
{endMarker ? endMarker.message : "Tap a marker to set end"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||||
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||||
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
|
{markerSaveError && <p className="text-sm text-[#B5432E]">{markerSaveError}</p>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
<PrimaryButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</PrimaryButton>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box className="p-5 flex flex-col gap-3">
|
<Box className="p-5 flex flex-col gap-3">
|
||||||
<CardHeader icon={Activity} title="Recent actions" />
|
<CardHeader icon={Activity} title="Recent actions" />
|
||||||
{jobs.length === 0 ? (
|
{jobs.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
|
<p className="text-sm text-muted">Clip and save actions will show up here.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||||
|
|||||||
Reference in New Issue
Block a user