Skip to content

Commit 18dee75

Browse files
authored
feat: review dashboard — mobile-first control center (Task 1E)
Review queue, detail page, config panel, pipeline status, server actions with fail-closed auth, field whitelists, bounded GROQ queries. 10 files, +1,686 lines.
1 parent 6ab3833 commit 18dee75

File tree

10 files changed

+1719
-0
lines changed

10 files changed

+1719
-0
lines changed

app/(dashboard)/dashboard/config/config-form.tsx

Lines changed: 597 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export const dynamic = "force-dynamic";
2+
3+
import { getEngineConfig } from "@/lib/config";
4+
import { ConfigForm } from "./config-form";
5+
6+
export default async function ConfigPage() {
7+
let config = null;
8+
let error = null;
9+
10+
try {
11+
config = await getEngineConfig();
12+
} catch (err) {
13+
error = err instanceof Error ? err.message : "Failed to load config";
14+
}
15+
16+
return (
17+
<div className="flex flex-col gap-6">
18+
<div>
19+
<h1 className="text-3xl font-bold tracking-tight">Engine Config</h1>
20+
<p className="text-muted-foreground">
21+
Configure the automated content engine. Changes propagate within 5 minutes.
22+
</p>
23+
</div>
24+
25+
{error ? (
26+
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6">
27+
<p className="text-sm text-destructive">{error}</p>
28+
<p className="mt-2 text-xs text-muted-foreground">
29+
Make sure the engineConfig singleton exists in Sanity Studio.
30+
</p>
31+
</div>
32+
) : config ? (
33+
<ConfigForm initialConfig={config} />
34+
) : (
35+
<div className="rounded-lg border p-6">
36+
<p className="text-sm text-muted-foreground">Loading configuration...</p>
37+
</div>
38+
)}
39+
</div>
40+
);
41+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
export const dynamic = "force-dynamic";
2+
3+
import { dashboardQuery } from "@/lib/sanity/dashboard";
4+
import {
5+
Card,
6+
CardContent,
7+
CardHeader,
8+
CardTitle,
9+
} from "@/components/ui/card";
10+
import { Badge } from "@/components/ui/badge";
11+
12+
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
13+
draft: { label: "Draft", color: "bg-gray-500" },
14+
researching: { label: "Researching", color: "bg-blue-500" },
15+
research_complete: { label: "Research Complete", color: "bg-blue-600" },
16+
scripting: { label: "Scripting", color: "bg-indigo-500" },
17+
script_complete: { label: "Script Complete", color: "bg-indigo-600" },
18+
generating_images: { label: "Generating Images", color: "bg-purple-500" },
19+
images_complete: { label: "Images Complete", color: "bg-purple-600" },
20+
generating_audio: { label: "Generating Audio", color: "bg-pink-500" },
21+
video_gen: { label: "Video Generation", color: "bg-orange-500" },
22+
pending_review: { label: "Pending Review", color: "bg-yellow-500" },
23+
approved: { label: "Approved", color: "bg-green-500" },
24+
published: { label: "Published", color: "bg-green-700" },
25+
rejected: { label: "Rejected", color: "bg-red-500" },
26+
failed: { label: "Failed", color: "bg-red-700" },
27+
};
28+
29+
const ALL_STATUSES = Object.keys(STATUS_LABELS);
30+
31+
const IN_PROGRESS_STATUSES = [
32+
"researching",
33+
"scripting",
34+
"generating_images",
35+
"generating_audio",
36+
"video_gen",
37+
];
38+
39+
interface PipelineVideo {
40+
_id: string;
41+
title: string;
42+
status: string;
43+
_updatedAt: string;
44+
}
45+
46+
export default async function PipelinePage() {
47+
// Fetch counts for all statuses in a single query
48+
const counts = await dashboardQuery<Record<string, number>>(
49+
`{
50+
${ALL_STATUSES.map(
51+
(s) =>
52+
`"${s}": count(*[_type == "automatedVideo" && status == "${s}"])`
53+
).join(",\n ")}
54+
}`
55+
);
56+
57+
// Fetch active workflows (in-progress videos)
58+
const activeVideos = await dashboardQuery<PipelineVideo[]>(
59+
`*[_type == "automatedVideo" && status in $statuses] | order(_updatedAt desc) [0..19] {
60+
_id, title, status, _updatedAt
61+
}`,
62+
{ statuses: IN_PROGRESS_STATUSES }
63+
);
64+
65+
// Fetch recent completions and failures
66+
const recentCompleted = await dashboardQuery<PipelineVideo[]>(
67+
`*[_type == "automatedVideo" && status in ["published", "approved", "rejected", "failed"]] | order(_updatedAt desc) [0..9] {
68+
_id, title, status, _updatedAt
69+
}`
70+
);
71+
72+
const totalVideos = Object.values(counts || {}).reduce(
73+
(sum, count) => sum + (count || 0),
74+
0
75+
);
76+
77+
return (
78+
<div className="flex flex-col gap-6">
79+
<div>
80+
<h1 className="text-3xl font-bold tracking-tight">Pipeline Status</h1>
81+
<p className="text-muted-foreground">
82+
Overview of {totalVideos} videos across all pipeline stages.
83+
</p>
84+
</div>
85+
86+
{/* Status Count Cards */}
87+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
88+
{ALL_STATUSES.map((status) => {
89+
const info = STATUS_LABELS[status];
90+
const count = counts?.[status] ?? 0;
91+
return (
92+
<Card key={status} className="relative overflow-hidden">
93+
<CardContent className="p-4">
94+
<div className="flex items-center gap-2">
95+
<span
96+
className={`inline-block h-2.5 w-2.5 rounded-full ${info.color}`}
97+
/>
98+
<span className="text-xs font-medium text-muted-foreground truncate">
99+
{info.label}
100+
</span>
101+
</div>
102+
<p className="mt-2 text-2xl font-bold">{count}</p>
103+
</CardContent>
104+
</Card>
105+
);
106+
})}
107+
</div>
108+
109+
<div className="grid gap-4 md:grid-cols-2">
110+
{/* Active Workflows */}
111+
<Card>
112+
<CardHeader>
113+
<CardTitle>Active Workflows</CardTitle>
114+
</CardHeader>
115+
<CardContent>
116+
{activeVideos.length === 0 ? (
117+
<p className="text-sm text-muted-foreground">
118+
No videos currently in progress.
119+
</p>
120+
) : (
121+
<div className="space-y-3">
122+
{activeVideos.map((video) => {
123+
const info = STATUS_LABELS[video.status] || {
124+
label: video.status,
125+
color: "bg-gray-500",
126+
};
127+
return (
128+
<div
129+
key={video._id}
130+
className="flex items-center gap-3 rounded-md border p-3"
131+
>
132+
<span
133+
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${info.color}`}
134+
/>
135+
<div className="flex-1 min-w-0">
136+
<p className="text-sm font-medium truncate">
137+
{video.title || "Untitled"}
138+
</p>
139+
<p className="text-xs text-muted-foreground">
140+
{info.label}{" "}
141+
{new Date(video._updatedAt).toLocaleString()}
142+
</p>
143+
</div>
144+
</div>
145+
);
146+
})}
147+
</div>
148+
)}
149+
</CardContent>
150+
</Card>
151+
152+
{/* Recent Completions / Failures */}
153+
<Card>
154+
<CardHeader>
155+
<CardTitle>Recent Completions & Failures</CardTitle>
156+
</CardHeader>
157+
<CardContent>
158+
{recentCompleted.length === 0 ? (
159+
<p className="text-sm text-muted-foreground">
160+
No recent completions or failures.
161+
</p>
162+
) : (
163+
<div className="space-y-3">
164+
{recentCompleted.map((video) => {
165+
const info = STATUS_LABELS[video.status] || {
166+
label: video.status,
167+
color: "bg-gray-500",
168+
};
169+
return (
170+
<div
171+
key={video._id}
172+
className="flex items-center gap-3 rounded-md border p-3"
173+
>
174+
<Badge
175+
variant={
176+
video.status === "published" || video.status === "approved"
177+
? "default"
178+
: "destructive"
179+
}
180+
className="text-xs shrink-0"
181+
>
182+
{info.label}
183+
</Badge>
184+
<div className="flex-1 min-w-0">
185+
<p className="text-sm font-medium truncate">
186+
{video.title || "Untitled"}
187+
</p>
188+
<p className="text-xs text-muted-foreground">
189+
{new Date(video._updatedAt).toLocaleString()}
190+
</p>
191+
</div>
192+
</div>
193+
);
194+
})}
195+
</div>
196+
)}
197+
</CardContent>
198+
</Card>
199+
</div>
200+
</div>
201+
);
202+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export const dynamic = "force-dynamic";
2+
3+
import { notFound } from "next/navigation";
4+
import Link from "next/link";
5+
import { dashboardQuery } from "@/lib/sanity/dashboard";
6+
import { Button } from "@/components/ui/button";
7+
import { ArrowLeft } from "lucide-react";
8+
import { ReviewDetailClient } from "./review-detail-client";
9+
10+
interface Props {
11+
params: Promise<{ id: string }>;
12+
}
13+
14+
export default async function ReviewDetailPage({ params }: Props) {
15+
const { id } = await params;
16+
17+
const video = await dashboardQuery(
18+
`*[_type == "automatedVideo" && _id == $id][0] {
19+
_id,
20+
title,
21+
qualityScore,
22+
qualityIssues,
23+
status,
24+
_updatedAt,
25+
script,
26+
"infographicsHorizontal": infographicsHorizontal[] {
27+
_key,
28+
"asset": asset-> { url }
29+
}
30+
}`,
31+
{ id }
32+
);
33+
34+
if (!video) {
35+
notFound();
36+
}
37+
38+
return (
39+
<div className="flex flex-col gap-6">
40+
<div>
41+
<Link href="/dashboard/review">
42+
<Button variant="ghost" size="sm" className="min-h-[44px] gap-1">
43+
<ArrowLeft className="h-4 w-4" />
44+
Back to Review Queue
45+
</Button>
46+
</Link>
47+
</div>
48+
<ReviewDetailClient video={video as any} />
49+
</div>
50+
);
51+
}

0 commit comments

Comments
 (0)