feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import AdminPageHeader from "@/components/admin/admin-page-header";
|
||||
import CategoryForm from "@/components/admin/category-form";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
type EditCategoryPageProps = {
|
||||
params: { id: string };
|
||||
};
|
||||
|
||||
export default async function EditCategoryPage({ params }: EditCategoryPageProps) {
|
||||
const category = await prisma.category.findUnique({ where: { id: params.id } });
|
||||
|
||||
if (!category) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AdminPageHeader
|
||||
title="Edit category"
|
||||
description={`Update ${category.nameEn} and keep its slug stable for public links.`}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/categories">Back to categories</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CategoryForm
|
||||
mode="edit"
|
||||
categoryId={category.id}
|
||||
defaultValues={{
|
||||
kind: category.kind,
|
||||
slug: category.slug,
|
||||
nameAr: category.nameAr,
|
||||
nameEn: category.nameEn,
|
||||
order: category.order,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { categoryIdSchema, categorySchema } from "@/lib/validations/category";
|
||||
|
||||
export type CategoryActionResult = {
|
||||
success?: true;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function isAdminAuthenticated() {
|
||||
const session = await auth();
|
||||
return Boolean(session?.user);
|
||||
}
|
||||
|
||||
function getValidationError(error: unknown) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return "A category with this slug already exists.";
|
||||
}
|
||||
|
||||
return "Unable to save the category right now.";
|
||||
}
|
||||
|
||||
export async function createCategory(input: unknown): Promise<CategoryActionResult> {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return { error: "Your session has expired. Please sign in again." };
|
||||
}
|
||||
|
||||
const parsed = categorySchema.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? "Invalid category data." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.category.create({ data: parsed.data });
|
||||
revalidatePath("/admin/categories");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: getValidationError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCategory(categoryId: string, input: unknown): Promise<CategoryActionResult> {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return { error: "Your session has expired. Please sign in again." };
|
||||
}
|
||||
|
||||
const validId = categoryIdSchema.safeParse(categoryId);
|
||||
const parsed = categorySchema.safeParse(input);
|
||||
|
||||
if (!validId.success) {
|
||||
return { error: "Invalid category id." };
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? "Invalid category data." };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.category.update({ where: { id: validId.data }, data: parsed.data });
|
||||
revalidatePath("/admin/categories");
|
||||
revalidatePath(`/admin/categories/${validId.data}/edit`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: getValidationError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCategory(formData: FormData) {
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
redirect("/admin/login");
|
||||
}
|
||||
|
||||
const validId = categoryIdSchema.safeParse(formData.get("categoryId"));
|
||||
|
||||
if (!validId.success) {
|
||||
redirect("/admin/categories?error=invalid-id");
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.category.delete({ where: { id: validId.data } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
redirect("/admin/categories?error=category-in-use");
|
||||
}
|
||||
|
||||
redirect("/admin/categories?error=delete-failed");
|
||||
}
|
||||
|
||||
revalidatePath("/admin/categories");
|
||||
redirect("/admin/categories");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import AdminPageHeader from "@/components/admin/admin-page-header";
|
||||
import CategoryForm from "@/components/admin/category-form";
|
||||
|
||||
export default function NewCategoryPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AdminPageHeader
|
||||
title="New category"
|
||||
description="Create a category that can be reused across the public content sections."
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/categories">Back to categories</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CategoryForm
|
||||
mode="create"
|
||||
defaultValues={{ kind: "PROJECT", slug: "", nameAr: "", nameEn: "", order: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { CategoryKind } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import AdminPageHeader from "@/components/admin/admin-page-header";
|
||||
import { deleteCategory } from "@/app/admin/(protected)/categories/actions";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const kindLabels: Record<CategoryKind, string> = {
|
||||
PROJECT: "Projects",
|
||||
MELODY: "Melodies",
|
||||
};
|
||||
|
||||
type CategoriesPageProps = {
|
||||
searchParams: { error?: string };
|
||||
};
|
||||
|
||||
function getErrorMessage(error?: string) {
|
||||
if (error === "category-in-use") return "This category cannot be deleted while content is linked to it.";
|
||||
if (error === "invalid-id") return "The selected category id is invalid.";
|
||||
if (error === "delete-failed") return "The category could not be deleted.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function CategoriesPage({ searchParams }: CategoriesPageProps) {
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: [{ order: "asc" }, { nameEn: "asc" }],
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true, melodies: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const errorMessage = getErrorMessage(searchParams.error);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<AdminPageHeader
|
||||
title="Categories"
|
||||
description="Organize projects and melodies into reusable sections."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href="/admin/categories/new">New category</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="mb-6 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">No categories yet. Create the first one to organize content.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{categories.map((category) => (
|
||||
<Card key={category.id}>
|
||||
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-2">
|
||||
{kindLabels[category.kind]}
|
||||
</p>
|
||||
<CardTitle className="mt-2 text-lg">{category.nameEn}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground" dir="rtl">
|
||||
{category.nameAr}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-pill border border-border px-2 py-1 text-xs text-muted-foreground">
|
||||
#{category.order}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">/{category.slug}</p>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{category._count.projects + category._count.melodies} linked items
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/admin/categories/${category.id}/edit`}>Edit</Link>
|
||||
</Button>
|
||||
<form action={deleteCategory}>
|
||||
<input type="hidden" name="categoryId" value={category.id} />
|
||||
<Button type="submit" size="sm" variant="ghost">
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user