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:
2026-08-05 20:53:40 +02:00
parent d87f3033c6
commit c26f41511f
122 changed files with 15068 additions and 41 deletions
@@ -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>
);
}
+101
View File
@@ -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>
);
}