Initial Animatrix import
This commit is contained in:
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal 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
6
frontend/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
2167
frontend/package-lock.json
generated
Normal file
2167
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
45
frontend/src/app/dashboard/error.tsx
Normal file
45
frontend/src/app/dashboard/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/dashboard/page.tsx
Normal file
5
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "@/components/dashboard-client";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
199
frontend/src/app/globals.css
Normal file
199
frontend/src/app/globals.css
Normal 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);
|
||||
}
|
||||
53
frontend/src/app/jobs/[id]/error.tsx
Normal file
53
frontend/src/app/jobs/[id]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/app/jobs/[id]/page.tsx
Normal file
176
frontend/src/app/jobs/[id]/page.tsx
Normal 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>;
|
||||
}
|
||||
15
frontend/src/app/layout.tsx
Normal file
15
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/login/page.tsx
Normal file
5
frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <AuthForm mode="login" />;
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
5
frontend/src/app/register/page.tsx
Normal file
5
frontend/src/app/register/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthForm } from "@/components/auth-form";
|
||||
|
||||
export default function RegisterPage() {
|
||||
return <AuthForm mode="register" />;
|
||||
}
|
||||
92
frontend/src/components/auth-form.tsx
Normal file
92
frontend/src/components/auth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1161
frontend/src/components/dashboard-client.tsx
Normal file
1161
frontend/src/components/dashboard-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
165
frontend/src/components/video-player.tsx
Normal file
165
frontend/src/components/video-player.tsx
Normal 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
44
frontend/src/lib/api.ts
Normal 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
33
frontend/src/lib/time.ts
Normal 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
69
frontend/src/lib/types.ts
Normal 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;
|
||||
};
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
21
frontend/tailwind.config.js
Normal file
21
frontend/tailwind.config.js
Normal 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
24
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user