Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
597 changes: 597 additions & 0 deletions app/(dashboard)/dashboard/config/config-form.tsx

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions app/(dashboard)/dashboard/config/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const dynamic = "force-dynamic";

import { getEngineConfig } from "@/lib/config";
import { ConfigForm } from "./config-form";

export default async function ConfigPage() {
let config = null;
let error = null;

try {
config = await getEngineConfig();
} catch (err) {
error = err instanceof Error ? err.message : "Failed to load config";
}

return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Engine Config</h1>
<p className="text-muted-foreground">
Configure the automated content engine. Changes propagate within 5 minutes.
</p>
</div>

{error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6">
<p className="text-sm text-destructive">{error}</p>
<p className="mt-2 text-xs text-muted-foreground">
Make sure the engineConfig singleton exists in Sanity Studio.
</p>
</div>
) : config ? (
<ConfigForm initialConfig={config} />
) : (
<div className="rounded-lg border p-6">
<p className="text-sm text-muted-foreground">Loading configuration...</p>
</div>
)}
</div>
);
}
202 changes: 202 additions & 0 deletions app/(dashboard)/dashboard/pipeline/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
export const dynamic = "force-dynamic";

import { dashboardQuery } from "@/lib/sanity/dashboard";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";

const STATUS_LABELS: Record<string, { label: string; color: string }> = {
draft: { label: "Draft", color: "bg-gray-500" },
researching: { label: "Researching", color: "bg-blue-500" },
research_complete: { label: "Research Complete", color: "bg-blue-600" },
scripting: { label: "Scripting", color: "bg-indigo-500" },
script_complete: { label: "Script Complete", color: "bg-indigo-600" },
generating_images: { label: "Generating Images", color: "bg-purple-500" },
images_complete: { label: "Images Complete", color: "bg-purple-600" },
generating_audio: { label: "Generating Audio", color: "bg-pink-500" },
video_gen: { label: "Video Generation", color: "bg-orange-500" },
pending_review: { label: "Pending Review", color: "bg-yellow-500" },
approved: { label: "Approved", color: "bg-green-500" },
published: { label: "Published", color: "bg-green-700" },
rejected: { label: "Rejected", color: "bg-red-500" },
failed: { label: "Failed", color: "bg-red-700" },
};

const ALL_STATUSES = Object.keys(STATUS_LABELS);

const IN_PROGRESS_STATUSES = [
"researching",
"scripting",
"generating_images",
"generating_audio",
"video_gen",
];

interface PipelineVideo {
_id: string;
title: string;
status: string;
_updatedAt: string;
}

export default async function PipelinePage() {
// Fetch counts for all statuses in a single query
const counts = await dashboardQuery<Record<string, number>>(
`{
${ALL_STATUSES.map(
(s) =>
`"${s}": count(*[_type == "automatedVideo" && status == "${s}"])`
).join(",\n ")}
}`
);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Good: Pipeline queries are properly bounded

All three queries have proper limits:

  • Status counts: single aggregation query (efficient)
  • Active workflows: [0..19] (max 20)
  • Recent completions: [0..9] (max 10)

The status count query is clever — builds a single GROQ object with all counts in one round-trip. Well done.

// Fetch active workflows (in-progress videos)
const activeVideos = await dashboardQuery<PipelineVideo[]>(
`*[_type == "automatedVideo" && status in $statuses] | order(_updatedAt desc) [0..19] {
_id, title, status, _updatedAt
}`,
{ statuses: IN_PROGRESS_STATUSES }
);

// Fetch recent completions and failures
const recentCompleted = await dashboardQuery<PipelineVideo[]>(
`*[_type == "automatedVideo" && status in ["published", "approved", "rejected", "failed"]] | order(_updatedAt desc) [0..9] {
_id, title, status, _updatedAt
}`
);

const totalVideos = Object.values(counts || {}).reduce(
(sum, count) => sum + (count || 0),
0
);

return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pipeline Status</h1>
<p className="text-muted-foreground">
Overview of {totalVideos} videos across all pipeline stages.
</p>
</div>

{/* Status Count Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{ALL_STATUSES.map((status) => {
const info = STATUS_LABELS[status];
const count = counts?.[status] ?? 0;
return (
<Card key={status} className="relative overflow-hidden">
<CardContent className="p-4">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${info.color}`}
/>
<span className="text-xs font-medium text-muted-foreground truncate">
{info.label}
</span>
</div>
<p className="mt-2 text-2xl font-bold">{count}</p>
</CardContent>
</Card>
);
})}
</div>

<div className="grid gap-4 md:grid-cols-2">
{/* Active Workflows */}
<Card>
<CardHeader>
<CardTitle>Active Workflows</CardTitle>
</CardHeader>
<CardContent>
{activeVideos.length === 0 ? (
<p className="text-sm text-muted-foreground">
No videos currently in progress.
</p>
) : (
<div className="space-y-3">
{activeVideos.map((video) => {
const info = STATUS_LABELS[video.status] || {
label: video.status,
color: "bg-gray-500",
};
return (
<div
key={video._id}
className="flex items-center gap-3 rounded-md border p-3"
>
<span
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${info.color}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{video.title || "Untitled"}
</p>
<p className="text-xs text-muted-foreground">
{info.label} •{" "}
{new Date(video._updatedAt).toLocaleString()}
</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>

{/* Recent Completions / Failures */}
<Card>
<CardHeader>
<CardTitle>Recent Completions & Failures</CardTitle>
</CardHeader>
<CardContent>
{recentCompleted.length === 0 ? (
<p className="text-sm text-muted-foreground">
No recent completions or failures.
</p>
) : (
<div className="space-y-3">
{recentCompleted.map((video) => {
const info = STATUS_LABELS[video.status] || {
label: video.status,
color: "bg-gray-500",
};
return (
<div
key={video._id}
className="flex items-center gap-3 rounded-md border p-3"
>
<Badge
variant={
video.status === "published" || video.status === "approved"
? "default"
: "destructive"
}
className="text-xs shrink-0"
>
{info.label}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{video.title || "Untitled"}
</p>
<p className="text-xs text-muted-foreground">
{new Date(video._updatedAt).toLocaleString()}
</p>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
51 changes: 51 additions & 0 deletions app/(dashboard)/dashboard/review/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export const dynamic = "force-dynamic";

import { notFound } from "next/navigation";
import Link from "next/link";
import { dashboardQuery } from "@/lib/sanity/dashboard";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { ReviewDetailClient } from "./review-detail-client";

interface Props {
params: Promise<{ id: string }>;
}

export default async function ReviewDetailPage({ params }: Props) {
const { id } = await params;

const video = await dashboardQuery(
`*[_type == "automatedVideo" && _id == $id][0] {
_id,
title,
qualityScore,
qualityIssues,
status,
_updatedAt,
script,
"infographicsHorizontal": infographicsHorizontal[] {
_key,
"asset": asset-> { url }
}
}`,
{ id }
);

if (!video) {
notFound();
}

return (
<div className="flex flex-col gap-6">
<div>
<Link href="/dashboard/review">
<Button variant="ghost" size="sm" className="min-h-[44px] gap-1">
<ArrowLeft className="h-4 w-4" />
Back to Review Queue
</Button>
</Link>
</div>
<ReviewDetailClient video={video as any} />
</div>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔶 as any type cast on video prop

video as any bypasses type checking entirely. The dashboardQuery return type should match VideoData from the client component. Either:

  1. Add a generic type parameter: dashboardQuery<VideoData>(...)
  2. Or define a shared type and use it in both places

This is a code quality issue — the VideoData interface in review-detail-client.tsx should be the source of truth.

);
}
Loading