Skip to content

Commit 9918316

Browse files
author
Miriad
committed
Merge branch 'phase1e/dashboard' into dev
2 parents 756b4d3 + 00af3f8 commit 9918316

File tree

15 files changed

+995
-257
lines changed

15 files changed

+995
-257
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"use server";
2+
3+
import { redirect } from "next/navigation";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
export async function signOut() {
7+
const supabase = await createClient();
8+
await supabase.auth.signOut();
9+
redirect("/dashboard/login");
10+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createClient } from "@/lib/supabase/server";
2+
import { NextResponse } from "next/server";
3+
4+
export async function GET(request: Request) {
5+
const { searchParams, origin } = new URL(request.url);
6+
const code = searchParams.get("code");
7+
const next = searchParams.get("next") ?? "/dashboard";
8+
9+
if (code) {
10+
const supabase = await createClient();
11+
const { error } = await supabase.auth.exchangeCodeForSession(code);
12+
if (!error) {
13+
return NextResponse.redirect(`${origin}${next}`);
14+
}
15+
}
16+
17+
return NextResponse.redirect(
18+
`${origin}/dashboard/login?error=Could+not+authenticate`,
19+
);
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { dashboardClient } from "@/lib/sanity/dashboard";
5+
6+
export async function approveIdea(id: string) {
7+
await dashboardClient.patch(id).set({ status: "approved" }).commit();
8+
revalidatePath("/dashboard/content");
9+
}
10+
11+
export async function rejectIdea(id: string) {
12+
await dashboardClient.patch(id).set({ status: "rejected" }).commit();
13+
revalidatePath("/dashboard/content");
14+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import {
4+
Table,
5+
TableBody,
6+
TableCell,
7+
TableHead,
8+
TableHeader,
9+
TableRow,
10+
} from "@/components/ui/table";
11+
import { Badge } from "@/components/ui/badge";
12+
import { Button } from "@/components/ui/button";
13+
import { Lightbulb, ExternalLink } from "lucide-react";
14+
import { approveIdea, rejectIdea } from "./actions";
15+
16+
interface ContentIdea {
17+
_id: string;
18+
_createdAt: string;
19+
title: string;
20+
status: "new" | "approved" | "rejected";
21+
sourceUrl?: string;
22+
summary?: string;
23+
topics?: string[];
24+
collectedAt?: string;
25+
}
26+
27+
const statusConfig: Record<string, { label: string; className: string }> = {
28+
new: {
29+
label: "New",
30+
className:
31+
"bg-blue-100 text-blue-800 hover:bg-blue-100 dark:bg-blue-900 dark:text-blue-300",
32+
},
33+
approved: {
34+
label: "Approved",
35+
className:
36+
"bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-300",
37+
},
38+
rejected: {
39+
label: "Rejected",
40+
className:
41+
"bg-red-100 text-red-800 hover:bg-red-100 dark:bg-red-900 dark:text-red-300",
42+
},
43+
};
44+
45+
function StatusBadge({ status }: { status: string }) {
46+
const config = statusConfig[status] ?? { label: status, className: "" };
47+
return (
48+
<Badge variant="outline" className={config.className}>
49+
{config.label}
50+
</Badge>
51+
);
52+
}
53+
54+
function formatDate(dateString: string) {
55+
return new Date(dateString).toLocaleDateString("en-US", {
56+
month: "short",
57+
day: "numeric",
58+
year: "numeric",
59+
});
60+
}
61+
62+
function truncateUrl(url: string, maxLength = 30) {
63+
try {
64+
const parsed = new URL(url);
65+
const display = parsed.hostname + parsed.pathname;
66+
return display.length > maxLength
67+
? display.slice(0, maxLength) + "\u2026"
68+
: display;
69+
} catch {
70+
return url.length > maxLength ? url.slice(0, maxLength) + "\u2026" : url;
71+
}
72+
}
73+
74+
export function ContentIdeasTable({ ideas }: { ideas: ContentIdea[] }) {
75+
if (ideas.length === 0) {
76+
return (
77+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
78+
<Lightbulb className="h-12 w-12 text-muted-foreground/50" />
79+
<h3 className="mt-4 text-lg font-semibold">No content ideas yet</h3>
80+
<p className="mt-2 text-sm text-muted-foreground">
81+
Content ideas will appear here once the ideation cron starts
82+
running.
83+
</p>
84+
</div>
85+
);
86+
}
87+
88+
return (
89+
<div className="rounded-lg border">
90+
<Table>
91+
<TableHeader>
92+
<TableRow>
93+
<TableHead>Title</TableHead>
94+
<TableHead>Status</TableHead>
95+
<TableHead>Source</TableHead>
96+
<TableHead>Topics</TableHead>
97+
<TableHead>Collected</TableHead>
98+
<TableHead className="text-right">Actions</TableHead>
99+
</TableRow>
100+
</TableHeader>
101+
<TableBody>
102+
{ideas.map((idea) => (
103+
<TableRow key={idea._id}>
104+
<TableCell className="font-medium">
105+
{idea.title || "Untitled"}
106+
</TableCell>
107+
<TableCell>
108+
<StatusBadge status={idea.status} />
109+
</TableCell>
110+
<TableCell>
111+
{idea.sourceUrl ? (
112+
<a
113+
href={idea.sourceUrl}
114+
target="_blank"
115+
rel="noopener noreferrer"
116+
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
117+
>
118+
{truncateUrl(idea.sourceUrl)}
119+
<ExternalLink className="h-3 w-3" />
120+
</a>
121+
) : (
122+
<span className="text-muted-foreground">\u2014</span>
123+
)}
124+
</TableCell>
125+
<TableCell>
126+
{idea.topics && idea.topics.length > 0 ? (
127+
<div className="flex flex-wrap gap-1">
128+
{idea.topics.slice(0, 3).map((topic) => (
129+
<Badge key={topic} variant="secondary" className="text-xs">
130+
{topic}
131+
</Badge>
132+
))}
133+
{idea.topics.length > 3 && (
134+
<Badge variant="secondary" className="text-xs">
135+
+{idea.topics.length - 3}
136+
</Badge>
137+
)}
138+
</div>
139+
) : (
140+
<span className="text-muted-foreground">\u2014</span>
141+
)}
142+
</TableCell>
143+
<TableCell className="text-muted-foreground">
144+
{formatDate(idea.collectedAt || idea._createdAt)}
145+
</TableCell>
146+
<TableCell className="text-right">
147+
{idea.status === "new" && (
148+
<div className="flex justify-end gap-2">
149+
<form action={approveIdea.bind(null, idea._id)}>
150+
<Button
151+
size="sm"
152+
variant="outline"
153+
className="text-green-600 hover:text-green-700"
154+
>
155+
Approve
156+
</Button>
157+
</form>
158+
<form action={rejectIdea.bind(null, idea._id)}>
159+
<Button
160+
size="sm"
161+
variant="outline"
162+
className="text-red-600 hover:text-red-700"
163+
>
164+
Reject
165+
</Button>
166+
</form>
167+
</div>
168+
)}
169+
</TableCell>
170+
</TableRow>
171+
))}
172+
</TableBody>
173+
</Table>
174+
</div>
175+
);
176+
}
Lines changed: 46 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,49 @@
1-
export default function ContentPage() {
2-
return (
3-
<div className="flex flex-col gap-6">
4-
<div>
5-
<h1 className="text-3xl font-bold tracking-tight">
6-
Content Management
7-
</h1>
8-
<p className="text-muted-foreground">
9-
Manage content ideas and automated video pipeline.
10-
</p>
11-
</div>
1+
import { dashboardQuery } from "@/lib/sanity/dashboard";
2+
import { ContentIdeasTable } from "./content-ideas-table";
123

13-
<div className="grid gap-4 md:grid-cols-2">
14-
<div className="rounded-lg border p-6">
15-
<h2 className="text-lg font-semibold">Content Ideas</h2>
16-
<p className="mt-2 text-sm text-muted-foreground">
17-
List of contentIdea records from Sanity — topics, status, priority,
18-
and assigned categories. Will support filtering by status (draft,
19-
approved, published, flagged).
20-
</p>
21-
<div className="mt-4 rounded-md bg-muted p-4 text-sm text-muted-foreground">
22-
📋 Data table coming in Phase 1a
23-
</div>
24-
</div>
4+
interface ContentIdea {
5+
_id: string;
6+
_createdAt: string;
7+
title: string;
8+
status: "new" | "approved" | "rejected";
9+
sourceUrl?: string;
10+
summary?: string;
11+
topics?: string[];
12+
collectedAt?: string;
13+
}
14+
15+
const CONTENT_IDEAS_QUERY = `*[_type == "contentIdea"] | order(_createdAt desc) {
16+
_id,
17+
_createdAt,
18+
title,
19+
status,
20+
sourceUrl,
21+
summary,
22+
topics,
23+
collectedAt
24+
}`;
25+
26+
export default async function ContentPage() {
27+
let ideas: ContentIdea[] = [];
28+
29+
try {
30+
ideas = await dashboardQuery<ContentIdea[]>(CONTENT_IDEAS_QUERY);
31+
} catch (error) {
32+
console.error("Failed to fetch content ideas:", error);
33+
}
34+
35+
return (
36+
<div className="flex flex-col gap-6">
37+
<div>
38+
<h1 className="text-3xl font-bold tracking-tight">
39+
Content Ideas
40+
</h1>
41+
<p className="text-muted-foreground">
42+
Manage content ideas — approve, reject, or review incoming topics.
43+
</p>
44+
</div>
2545

26-
<div className="rounded-lg border p-6">
27-
<h2 className="text-lg font-semibold">Automated Videos</h2>
28-
<p className="mt-2 text-sm text-muted-foreground">
29-
List of automatedVideo records — YouTube publish status, view
30-
counts, and linked content ideas. Will show pipeline progress from
31-
idea → script → video → published.
32-
</p>
33-
<div className="mt-4 rounded-md bg-muted p-4 text-sm text-muted-foreground">
34-
🎬 Video pipeline view coming in Phase 1a
35-
</div>
36-
</div>
37-
</div>
38-
</div>
39-
)
46+
<ContentIdeasTable ideas={ideas} />
47+
</div>
48+
);
4049
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import {
4+
Card,
5+
CardContent,
6+
CardDescription,
7+
CardHeader,
8+
CardTitle,
9+
} from "@/components/ui/card";
10+
import { Input } from "@/components/ui/input";
11+
import { Label } from "@/components/ui/label";
12+
import { Button } from "@/components/ui/button";
13+
14+
export default async function LoginPage(props: {
15+
searchParams: Promise<{ error?: string }>;
16+
}) {
17+
const { error } = await props.searchParams;
18+
19+
async function signIn(formData: FormData) {
20+
"use server";
21+
22+
const email = formData.get("email") as string;
23+
const password = formData.get("password") as string;
24+
25+
const supabase = await createClient();
26+
const { error } = await supabase.auth.signInWithPassword({
27+
email,
28+
password,
29+
});
30+
31+
if (error) {
32+
redirect(`/dashboard/login?error=${encodeURIComponent(error.message)}`);
33+
}
34+
35+
redirect("/dashboard");
36+
}
37+
38+
return (
39+
<div className="flex min-h-screen items-center justify-center bg-background">
40+
<Card className="w-full max-w-sm">
41+
<CardHeader>
42+
<CardTitle className="text-2xl">CodingCat.dev</CardTitle>
43+
<CardDescription>
44+
Sign in to the Content Ops Dashboard
45+
</CardDescription>
46+
</CardHeader>
47+
<CardContent>
48+
<form action={signIn} className="flex flex-col gap-4">
49+
{error && (
50+
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
51+
{error}
52+
</div>
53+
)}
54+
<div className="flex flex-col gap-2">
55+
<Label htmlFor="email">Email</Label>
56+
<Input
57+
id="email"
58+
name="email"
59+
type="email"
60+
placeholder="admin@codingcat.dev"
61+
required
62+
/>
63+
</div>
64+
<div className="flex flex-col gap-2">
65+
<Label htmlFor="password">Password</Label>
66+
<Input
67+
id="password"
68+
name="password"
69+
type="password"
70+
required
71+
/>
72+
</div>
73+
<Button type="submit" className="w-full">
74+
Sign In
75+
</Button>
76+
</form>
77+
</CardContent>
78+
</Card>
79+
</div>
80+
);
81+
}

0 commit comments

Comments
 (0)