-
-
Notifications
You must be signed in to change notification settings - Fork 12
feat(dashboard): Task 1E — Mobile-first review dashboard with approve/reject, config panel, pipeline status #666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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> | ||
| ); | ||
| } |
| 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 ")} | ||
| }` | ||
| ); | ||
|
|
||
| // 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> | ||
| ); | ||
| } | ||
| 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> | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔶
This is a code quality issue — the |
||
| ); | ||
| } | ||
There was a problem hiding this comment.
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:
[0..19](max 20)[0..9](max 10)The status count query is clever — builds a single GROQ object with all counts in one round-trip. Well done.