Initial Animatrix import

This commit is contained in:
Sagnik
2026-04-17 19:11:57 +05:30
commit c7994d17a9
60 changed files with 8516 additions and 0 deletions

6
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

6
frontend/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;

2167
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "animatrix-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"next": "15.5.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.5",
"typescript": "^5.5.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("Animatrix dashboard route failed:", error);
}, [error]);
return (
<div className="shell">
<div className="panel p-6 sm:p-7">
<p className="eyebrow">Dashboard</p>
<h1 className="mt-2 text-3xl font-semibold text-white">Dashboard Runtime Error</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-subtext">
The dashboard hit a client-side render error. This route now fails closed with a local fallback instead of collapsing the entire application shell.
</p>
<div className="mt-5 rounded-[22px] border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error.message || "Unknown dashboard error"}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button className="btn-primary" onClick={reset} type="button">
<RefreshCw className="mr-2 h-4 w-4" />
Retry dashboard
</button>
<button className="btn-secondary" onClick={() => router.push("/login")} type="button">
Go to login
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { DashboardClient } from "@/components/dashboard-client";
export default function DashboardPage() {
return <DashboardClient />;
}

View File

@@ -0,0 +1,199 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--ink: #05070b;
--panel: rgba(13, 17, 24, 0.82);
--panel-strong: rgba(17, 22, 31, 0.94);
--soft: rgba(255, 255, 255, 0.05);
--soft-strong: rgba(255, 255, 255, 0.08);
--edge: rgba(255, 255, 255, 0.1);
--text: #f5f7fb;
--subtext: #97a3b6;
--accent: #72f4b7;
--accent-strong: #d0ffd9;
}
html,
body {
min-height: 100%;
width: 100%;
}
body {
@apply bg-ink text-text antialiased;
margin: 0;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
letter-spacing: -0.01em;
background:
radial-gradient(circle at 10% 10%, rgba(114, 244, 183, 0.16), transparent 22%),
radial-gradient(circle at 88% 12%, rgba(90, 139, 255, 0.12), transparent 24%),
linear-gradient(180deg, #0a0d12 0%, #05070b 42%, #040508 100%);
position: relative;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: radial-gradient(circle at center, black 48%, transparent 92%);
opacity: 0.28;
}
.app-root {
min-height: 100vh;
}
a {
@apply text-inherit no-underline;
}
* {
box-sizing: border-box;
}
*::selection {
background: rgba(114, 244, 183, 0.3);
color: var(--text);
}
.shell {
@apply mx-auto w-full max-w-[90rem] px-5 py-8 sm:px-6 lg:px-8;
}
.panel {
@apply rounded-[30px] border border-white/10 shadow-glow backdrop-blur-xl;
background: linear-gradient(180deg, rgba(18, 23, 31, 0.94) 0%, rgba(12, 16, 23, 0.88) 100%);
}
.input {
@apply w-full rounded-[22px] border border-white/10 px-4 py-3.5 text-sm text-text outline-none transition;
background: rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.input:focus {
border-color: rgba(114, 244, 183, 0.45);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
0 0 0 3px rgba(114, 244, 183, 0.08);
}
.btn {
@apply inline-flex items-center justify-center rounded-[20px] px-4 py-3 text-sm font-medium transition;
}
.btn-primary {
@apply btn text-black;
background: linear-gradient(135deg, #72f4b7 0%, #d0ffd9 100%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.15) inset,
0 18px 36px rgba(114, 244, 183, 0.18);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(0.98);
}
.btn-secondary {
@apply btn border border-white/10 text-text hover:bg-white/5;
background: rgba(255, 255, 255, 0.05);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.btn-secondary:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.14);
}
.chip {
@apply inline-flex items-center gap-2 rounded-[18px] border border-white/10 px-3 py-2 text-xs text-text;
background: rgba(255, 255, 255, 0.05);
}
.eyebrow {
@apply text-[11px] uppercase tracking-[0.28em] text-subtext;
}
.metric {
@apply rounded-[24px] border border-white/10 p-4;
background: rgba(255, 255, 255, 0.05);
}
.section-title {
@apply text-xl font-semibold text-text sm:text-2xl;
}
.muted {
@apply text-sm text-subtext;
}
.glass-overlay {
background:
linear-gradient(180deg, rgba(18, 24, 34, 0.88) 0%, rgba(10, 14, 20, 0.82) 100%);
box-shadow:
0 28px 90px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.video-slider {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(114, 244, 183, 0.78), rgba(255, 255, 255, 0.22));
outline: none;
}
.video-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: #f5f7fb;
box-shadow: 0 0 0 6px rgba(114, 244, 183, 0.12);
cursor: pointer;
}
.video-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: #f5f7fb;
box-shadow: 0 0 0 6px rgba(114, 244, 183, 0.12);
cursor: pointer;
}
.status-dot {
@apply inline-block h-2.5 w-2.5 rounded-full;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.22);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { RefreshCw, X } from "lucide-react";
import { useRouter } from "next/navigation";
export default function JobDetailError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("Animatrix job detail route failed:", error);
}, [error]);
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/65 px-4 py-6 backdrop-blur-md sm:px-6 lg:px-10">
<div className="glass-overlay w-full max-w-2xl rounded-[34px] border border-white/12 p-6 shadow-glow sm:p-8">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<p className="eyebrow">Generation Detail</p>
<h1 className="mt-2 text-3xl font-semibold text-white">Client Runtime Error</h1>
</div>
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => router.push("/dashboard")} type="button">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-subtext">
The detail overlay hit a client-side error. The route now has a guarded fallback instead of breaking the whole app shell.
</p>
<div className="mt-5 rounded-[22px] border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error.message || "Unknown client-side exception"}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button className="btn-primary" onClick={reset} type="button">
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</button>
<button className="btn-secondary" onClick={() => router.push("/dashboard")} type="button">
Back to dashboard
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Download, RefreshCw, X } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { VideoPlayer } from "@/components/video-player";
import { apiGet, apiUrl } from "@/lib/api";
import { formatIstDateTime } from "@/lib/time";
import type { Job } from "@/lib/types";
export default function JobPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const [job, setJob] = useState<Job | null>(null);
const [error, setError] = useState("");
const load = async () => {
try {
const data = await apiGet<Job>(`/api/jobs/${params.id}`);
setJob(data);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load generation detail");
if ((err as Error).message?.includes("Not authenticated")) {
router.push("/login");
}
}
};
useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5000);
return () => clearInterval(timer);
}, [params.id, router]);
const primaryOutput = useMemo(() => job?.outputs?.[0] ?? null, [job]);
const videoOutput = useMemo(
() => job?.outputs?.find((output) => output.output_type === "video") ?? primaryOutput,
[job, primaryOutput]
);
const outputs = job?.outputs ?? [];
const events = job?.events ?? [];
return (
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/65 px-4 py-6 backdrop-blur-md sm:px-6 lg:px-10">
<div className="mx-auto max-w-[92rem]">
<div className="glass-overlay rounded-[34px] border border-white/12 p-4 shadow-glow sm:p-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<p className="eyebrow">Generation Detail</p>
<h1 className="mt-2 text-3xl font-semibold text-white sm:text-4xl">Job {params.id}</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-subtext">
Review the generated output, runtime events, and download the final render from this overlay without leaving the dashboard flow.
</p>
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => void load()} type="button">
<RefreshCw className="h-4 w-4" />
</button>
<button className="btn-secondary h-11 w-11 rounded-full p-0" onClick={() => router.push("/dashboard")} type="button">
<X className="h-4 w-4" />
</button>
</div>
</div>
{error ? <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">{error}</div> : null}
{!job ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-10 text-subtext">Loading generation detail...</div>
) : (
<div className="space-y-6">
<section className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
<div className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusChip status={job.status} />
<div className="text-xs uppercase tracking-[0.22em] text-subtext">{job.workflow_template_name ?? "workflow pending"}</div>
</div>
{videoOutput ? (
<a className="btn-secondary" href={apiUrl(`/api/jobs/${job.id}/outputs/${videoOutput.id}/download`)}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
) : null}
</div>
{videoOutput?.output_type === "video" ? (
<VideoPlayer
poster={videoOutput.poster_path ? apiUrl(`/storage/outputs/${videoOutput.poster_path}`) : null}
src={apiUrl(`/storage/outputs/${videoOutput.file_path}`)}
/>
) : (
<div className="flex aspect-video items-center justify-center rounded-[28px] border border-white/10 bg-black/20 text-subtext">
No output video yet.
</div>
)}
</div>
<div className="space-y-6">
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
<div className="mb-3 text-[11px] uppercase tracking-[0.24em] text-subtext">Prompt</div>
<p className="text-sm leading-7 text-white/90">{job.prompt}</p>
{job.negative_prompt ? (
<div className="mt-4 rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.24em] text-subtext">Negative Prompt</div>
<div className="mt-2 text-sm text-subtext">{job.negative_prompt}</div>
</div>
) : null}
{job.error_message ? (
<div className="mt-4 rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{job.error_message}
</div>
) : null}
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
<div className="mb-4 text-[11px] uppercase tracking-[0.24em] text-subtext">Output Files</div>
<div className="space-y-3">
{outputs.length === 0 ? <div className="text-sm text-subtext">No outputs persisted yet.</div> : null}
{outputs.map((output) => (
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3" key={output.id}>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-white">{output.output_type}</div>
<div className="mt-1 text-xs text-subtext">{formatIstDateTime(output.created_at)}</div>
</div>
<a className="btn-secondary" href={apiUrl(`/api/jobs/${job.id}/outputs/${output.id}/download`)}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</div>
</div>
))}
</div>
</section>
</div>
</section>
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-xl font-semibold text-white">Runtime Events</div>
<div className="text-sm text-subtext">{events.length} event{events.length === 1 ? "" : "s"}</div>
</div>
<div className="grid gap-3 xl:grid-cols-2">
{events.map((event) => (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4" key={event.id}>
<div className="mb-2 flex items-start justify-between gap-4">
<div className="text-sm font-medium text-white">{event.event_type}</div>
<div className="text-xs uppercase tracking-[0.18em] text-subtext">{formatIstDateTime(event.created_at)}</div>
</div>
<div className="text-sm leading-6 text-subtext">{event.message}</div>
</div>
))}
</div>
{events.length === 0 ? <div className="text-sm text-subtext">No runtime events available yet.</div> : null}
</section>
</div>
)}
</div>
</div>
</div>
);
}
function StatusChip({ status }: { status: string }) {
const tone =
status === "completed"
? "border-emerald-400/25 bg-emerald-400/10 text-emerald-200"
: status === "failed"
? "border-red-400/25 bg-red-400/10 text-red-200"
: "border-amber-300/25 bg-amber-300/10 text-amber-100";
return <div className={`chip ${tone}`}>{status}</div>;
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Animatrix",
description: "Animatrix generation workspace"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="app-root">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,5 @@
import { AuthForm } from "@/components/auth-form";
export default function LoginPage() {
return <AuthForm mode="login" />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,5 @@
import { AuthForm } from "@/components/auth-form";
export default function RegisterPage() {
return <AuthForm mode="register" />;
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiPost } from "@/lib/api";
export function AuthForm({ mode }: { mode: "login" | "register" }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
setError("");
try {
const path = mode === "login" ? "/api/auth/login" : "/api/auth/register";
await apiPost(path, { email, password });
if (mode === "register") {
await apiPost("/api/auth/login", { email, password });
}
router.push("/dashboard");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
} finally {
setBusy(false);
}
};
return (
<div className="shell flex min-h-screen items-center justify-center py-14">
<section className="panel w-full max-w-md p-8 sm:p-10">
<div className="mb-8">
<p className="eyebrow">Animatrix</p>
<h1 className="mt-3 text-3xl font-semibold text-white">
{mode === "login" ? "Sign in" : "Create account"}
</h1>
<p className="mt-3 text-sm leading-6 text-subtext">
{mode === "login"
? "Use your workspace account to open the generator."
: "Create a local account for this workspace."}
</p>
</div>
<form className="space-y-4" onSubmit={submit}>
<input
autoComplete="email"
className="input"
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
autoComplete={mode === "login" ? "current-password" : "new-password"}
className="input"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error ? (
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</div>
) : null}
<button className="btn-primary w-full" disabled={busy} type="submit">
{busy ? "Working..." : mode === "login" ? "Sign in" : "Create account"}
</button>
</form>
<div className="mt-6 text-sm text-subtext">
{mode === "login" ? (
<>
No account yet? <Link className="text-accent" href="/register">Register</Link>
</>
) : (
<>
Already registered? <Link className="text-accent" href="/login">Sign in</Link>
</>
)}
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Maximize2, Pause, Play, Volume2, VolumeX } from "lucide-react";
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return "0:00";
}
const rounded = Math.floor(seconds);
const mins = Math.floor(rounded / 60);
const secs = rounded % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function VideoPlayer({ src, poster }: { src: string; poster?: string | null }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const syncState = () => {
setCurrentTime(video.currentTime || 0);
setDuration(video.duration || 0);
setProgress(video.duration ? (video.currentTime / video.duration) * 100 : 0);
setIsPlaying(!video.paused && !video.ended);
setIsMuted(video.muted);
setVolume(video.volume);
};
syncState();
video.addEventListener("timeupdate", syncState);
video.addEventListener("loadedmetadata", syncState);
video.addEventListener("play", syncState);
video.addEventListener("pause", syncState);
video.addEventListener("volumechange", syncState);
video.addEventListener("ended", syncState);
return () => {
video.removeEventListener("timeupdate", syncState);
video.removeEventListener("loadedmetadata", syncState);
video.removeEventListener("play", syncState);
video.removeEventListener("pause", syncState);
video.removeEventListener("volumechange", syncState);
video.removeEventListener("ended", syncState);
};
}, []);
const progressLabel = useMemo(
() => `${formatDuration(currentTime)} / ${formatDuration(duration)}`,
[currentTime, duration]
);
const togglePlayback = async () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
await video.play();
return;
}
video.pause();
};
const handleSeek = (value: number) => {
const video = videoRef.current;
if (!video || !video.duration) return;
const nextTime = (value / 100) * video.duration;
video.currentTime = nextTime;
setProgress(value);
setCurrentTime(nextTime);
};
const handleVolume = (value: number) => {
const video = videoRef.current;
if (!video) return;
const nextVolume = value / 100;
video.volume = nextVolume;
video.muted = nextVolume === 0;
setVolume(nextVolume);
setIsMuted(video.muted);
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const enterFullscreen = async () => {
const video = videoRef.current;
if (!video) return;
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await video.requestFullscreen();
};
return (
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-[rgba(255,255,255,0.03)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]">
<div className="aspect-video overflow-hidden bg-black">
<video
ref={videoRef}
className="h-full w-full object-contain"
playsInline
preload="metadata"
poster={poster ?? undefined}
src={src}
/>
</div>
<div className="space-y-4 px-4 py-4 sm:px-5">
<div>
<input
aria-label="Playback progress"
className="video-slider"
max={100}
min={0}
onChange={(event) => handleSeek(Number(event.target.value))}
type="range"
value={progress}
/>
<div className="mt-2 flex items-center justify-between text-xs text-subtext">
<span>{progressLabel}</span>
<span>MP4 output</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={() => void togglePlayback()} type="button">
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="ml-0.5 h-4 w-4" />}
</button>
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={toggleMute} type="button">
{isMuted || volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<div className="min-w-[8rem] flex-1 sm:max-w-44">
<input
aria-label="Volume"
className="video-slider"
max={100}
min={0}
onChange={(event) => handleVolume(Number(event.target.value))}
type="range"
value={Math.round((isMuted ? 0 : volume) * 100)}
/>
</div>
<button className="btn-secondary h-11 min-w-11 rounded-full p-0" onClick={() => void enterFullscreen()} type="button">
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

44
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,44 @@
const rawBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? "";
export const API_BASE_URL = rawBaseUrl.replace(/\/+$/, "");
export function apiUrl(path: string): string {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${API_BASE_URL}${normalized}`;
}
async function parse<T>(response: Response): Promise<T> {
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function apiGet<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path), {
credentials: "include",
cache: "no-store"
});
return parse<T>(response);
}
export async function apiPost<T>(path: string, body: BodyInit | object, isForm = false): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "POST",
credentials: "include",
headers: isForm ? undefined : { "Content-Type": "application/json" },
body: isForm ? (body as BodyInit) : JSON.stringify(body)
});
return parse<T>(response);
}
export async function apiDelete<T>(path: string, body?: object): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "DELETE",
credentials: "include",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
return parse<T>(response);
}

33
frontend/src/lib/time.ts Normal file
View File

@@ -0,0 +1,33 @@
const IST_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Kolkata",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
function normalizeDateInput(value: string | Date): Date {
if (value instanceof Date) {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return new Date(Number.NaN);
}
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(normalized);
return new Date(hasTimezone ? normalized : `${normalized}Z`);
}
export function formatIstDateTime(value: string | Date): string {
const date = normalizeDateInput(value);
if (Number.isNaN(date.getTime())) {
return "";
}
return `${IST_FORMATTER.format(date)} IST`;
}

69
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export type User = {
id: string;
email: string;
created_at: string;
};
export type Asset = {
id: string;
asset_type: string;
mime_type: string;
original_filename: string;
storage_path: string;
size_bytes: number;
width?: number | null;
height?: number | null;
duration_seconds?: number | null;
thumbnail_path?: string | null;
is_trashed?: boolean;
delete_after_at?: string | null;
created_at: string;
};
export type JobOutput = {
id: string;
output_type: string;
file_path: string;
poster_path?: string | null;
created_at: string;
};
export type JobEvent = {
id: string;
event_type: string;
message?: string | null;
created_at: string;
};
export type Job = {
id: string;
mode: string;
submode?: string | null;
prompt: string;
negative_prompt?: string | null;
status: string;
comfy_prompt_id?: string | null;
workflow_template_name?: string | null;
error_message?: string | null;
ground_truth_asset_id?: string | null;
motion_asset_id?: string | null;
audio_asset_id?: string | null;
pose_asset_id?: string | null;
reference_asset_ids?: string[] | null;
outputs: JobOutput[];
events: JobEvent[];
created_at: string;
updated_at: string;
};
export type AdminHealth = {
api: string;
comfyui: boolean;
};
export type JobsSummary = {
total: number;
active: number;
completed: number;
failed: number;
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
ink: "#0a0a0d",
panel: "#141419",
soft: "#1c1d24",
edge: "#2a2c35",
text: "#f2f4f8",
subtext: "#99a1b3",
accent: "#1ed760"
},
boxShadow: {
glow: "0 0 0 1px rgba(255,255,255,0.06), 0 20px 80px rgba(0,0,0,0.35)"
}
}
},
plugins: []
};

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}