Refactor code

This commit is contained in:
2026-02-03 12:17:47 +01:00
parent ea5eb6fa59
commit 8572e22c5d
185 changed files with 1268 additions and 1458 deletions

View File

@ -8,6 +8,7 @@ import ArtworkVariants from "@/components/artworks/single/ArtworkVariants";
import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton";
import EditArtworkForm from "@/components/artworks/single/EditArtworkForm";
// Single artwork edit page.
export default async function ArtworkSinglePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@ -16,30 +17,30 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
const categories = await getCategoriesWithTags();
const tags = await getTags();
if (!item) return <div>Artwork with this id not found</div>
if (!item) return <div>Artwork with this id not found</div>;
return (
<div>
<h1 className="text-2xl font-bold mb-4">Edit artwork</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
{item ? <EditArtworkForm artwork={item} tags={tags} categories={categories} /> : 'Artwork not found...'}
<EditArtworkForm artwork={item} tags={tags} categories={categories} />
<div>
{item && <DeleteArtworkButton artworkId={item.id} />}
<DeleteArtworkButton artworkId={item.id} />
</div>
<div>
{item && <ArtworkTimelapse artworkId={item.id} timelapse={item.timelapse} />}
<ArtworkTimelapse artworkId={item.id} timelapse={item.timelapse} />
</div>
</div>
<div className="space-y-6">
<div>
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
<ArtworkColors colors={item.colors} artworkId={item.id} />
</div>
<div>
{item && <ArtworkDetails artwork={item} />}
<ArtworkDetails artwork={item} />
</div>
<div>
{item && <ArtworkVariants artworkId={item.id} variants={item.variants} />}
<ArtworkVariants artworkId={item.id} variants={item.variants} />
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
// Admin artworks list page.
export default async function ArtworksPage() {
return (
<div className="space-y-6">

View File

@ -1,13 +1,14 @@
import EditCategoryForm from "@/components/categories/EditCategoryForm";
import { prisma } from "@/lib/prisma";
// Edit category page.
export default async function PortfolioCategoriesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const category = await prisma.artCategory.findUnique({
where: {
id,
}
})
},
});
return (
<div>
@ -15,4 +16,4 @@ export default async function PortfolioCategoriesEditPage({ params }: { params:
{category && <EditCategoryForm category={category} />}
</div>
);
}
}

View File

@ -1,5 +1,6 @@
import NewCategoryForm from "@/components/categories/NewCategoryForm";
// Create a new category page.
export default function PortfolioCategoriesNewPage() {
return (
<div>
@ -7,4 +8,4 @@ export default function PortfolioCategoriesNewPage() {
<NewCategoryForm />
</div>
);
}
}

View File

@ -4,6 +4,7 @@ import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
// Admin categories management page.
export default async function CategoriesPage() {
const items = await getCategoriesWithCount();
@ -11,7 +12,10 @@ export default async function CategoriesPage() {
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Categories</h1>
<Link href="/categories/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<Link
href="/categories/new"
className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded"
>
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new category
</Link>
</div>
@ -24,4 +28,4 @@ export default async function CategoriesPage() {
</Suspense>
</div>
);
}
}

View File

@ -1,8 +1,8 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import EditCustomCardForm from "@/components/commissions/customCards/EditCustomCardForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
// Edit custom commission card page.
export default async function CommissionCustomCardEditPage({
params,
}: {
@ -10,7 +10,7 @@ export default async function CommissionCustomCardEditPage({
}) {
const { id } = await params;
const [card, options, extras, images, tags] = await Promise.all([
const [card, options, extras, tags] = await Promise.all([
prisma.commissionCustomCard.findUnique({
where: { id },
include: {
@ -21,7 +21,6 @@ export default async function CommissionCustomCardEditPage({
}),
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
prisma.tag.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
]);
@ -38,7 +37,6 @@ export default async function CommissionCustomCardEditPage({
card={card}
allOptions={options}
allExtras={extras}
images={images}
allTags={tags}
/>
</div>

View File

@ -1,12 +1,11 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import NewCustomCardForm from "@/components/commissions/customCards/NewCustomCardForm";
import { prisma } from "@/lib/prisma";
// New custom commission card page.
export default async function CommissionCustomCardsNewPage() {
const [options, extras, images, tags] = await Promise.all([
const [options, extras, tags] = await Promise.all([
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
prisma.tag.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
]);
@ -15,7 +14,7 @@ export default async function CommissionCustomCardsNewPage() {
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Custom Commission Card</h1>
</div>
<NewCustomCardForm options={options} extras={extras} images={images} tags={tags} />
<NewCustomCardForm options={options} extras={extras} tags={tags} />
</div>
);
}

View File

@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
// Custom commission cards list page.
export default async function CommissionCustomCardsPage() {
const cards = await prisma.commissionCustomCard.findMany({
include: {

View File

@ -2,6 +2,7 @@ import { listCommissionExamples } from "@/actions/commissions/examples";
import { getActiveGuidelines } from "@/actions/commissions/guidelines/getGuidelines";
import GuidelinesEditor from "@/components/commissions/guidelines/Editor";
// Admin page for editing commission guidelines.
export default async function CommissionGuidelinesPage() {
const [{ markdown, exampleImageUrl }, examples] = await Promise.all([
getActiveGuidelines(),

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import type { BoardItem, ColumnsState } from "@/types/Board";
// Admin kanban page for commission requests.
export default async function CommissionsBoardPage() {
const requests = await prisma.commissionRequest.findMany({
where: {

View File

@ -2,6 +2,7 @@ import { getCommissionRequestById } from "@/actions/commissions/requests/getComm
import { CommissionRequestEditor } from "@/components/commissions/requests/CommissionRequestEditor";
import { notFound } from "next/navigation";
// Admin page for editing a single commission request.
export default async function CommissionRequestPage({
params,
}: {
@ -20,7 +21,7 @@ export default async function CommissionRequestPage({
Submitted: {new Date(request.createdAt).toLocaleString()} · ID: {request.id}
</p>
</div>
<CommissionRequestEditor request={request as any} />
<CommissionRequestEditor request={request} />
</div>
</div>
);

View File

@ -1,6 +1,7 @@
import RequestsTable from "@/components/commissions/requests/RequestsTable";
import { prisma } from "@/lib/prisma";
// Server-rendered commissions list page.
export default async function CommissionPage() {
const items = await prisma.commissionRequest.findMany({
include: {
@ -15,11 +16,11 @@ export default async function CommissionPage() {
<div>
<h1 className="text-2xl font-semibold">Commission Requests</h1>
<p className="text-sm text-muted-foreground">
List of all incomming requests via website.
List of all incoming requests via website.
</p>
</div>
<RequestsTable requests={items} />
</div>
</div>
);
}
}

View File

@ -1,6 +1,7 @@
import EditTypeForm from "@/components/commissions/types/EditTypeForm";
import { prisma } from "@/lib/prisma";
// Edit commission type page.
export default async function CommissionTypesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const commissionType = await prisma.commissionType.findUnique({
@ -13,7 +14,7 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
tags: true,
},
})
});
const tags = await prisma.tag.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
@ -22,13 +23,10 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
});
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
// const customInputs = await prisma.commissionCustomInput.findMany({
// orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
// })
});
if (!commissionType) {
return <div>Type not found</div>
return <div>Type not found</div>;
}
return (

View File

@ -1,10 +1,11 @@
import { ExtraListClient } from "@/components/commissions/extras/ExtraListClient";
import { prisma } from "@/lib/prisma";
// Admin page for managing commission extras.
export default async function CommissionTypesExtrasPage() {
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
});
return <ExtraListClient extras={extras} />;
}
}

View File

@ -1,6 +1,7 @@
import NewTypeForm from "@/components/commissions/types/NewTypeForm";
import { prisma } from "@/lib/prisma";
// Create new commission type page.
export default async function CommissionTypesNewPage() {
const tags = await prisma.tag.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
@ -10,10 +11,10 @@ export default async function CommissionTypesNewPage() {
});
const extras = await prisma.commissionExtra.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
});
const customInputs = await prisma.commissionCustomInput.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
});
return (
<div>
@ -27,6 +28,5 @@ export default async function CommissionTypesNewPage() {
tags={tags}
/>
</div>
);
}

View File

@ -1,10 +1,11 @@
import { OptionsListClient } from "@/components/commissions/options/OptionsListClient";
import { prisma } from "@/lib/prisma";
// Admin page for managing commission options.
export default async function CommissionTypesOptionsPage() {
const options = await prisma.commissionOption.findMany({
orderBy: [{ createdAt: "asc" }, { name: "asc" }],
});
return <OptionsListClient options={options} />;
}
}

View File

@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
// Commission types list page.
export default async function CommissionTypesPage() {
const types = await prisma.commissionType.findMany({
include: {
@ -17,11 +18,18 @@ export default async function CommissionTypesPage() {
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Commission Types</h1>
<Link href="/commissions/types/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<Link
href="/commissions/types/new"
className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded"
>
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Type
</Link>
</div>
{types && types.length > 0 ? <ListTypes types={types} /> : <p className="text-muted-foreground italic">No types found.</p>}
{types && types.length > 0 ? (
<ListTypes types={types} />
) : (
<p className="text-muted-foreground italic">No types found.</p>
)}
</div>
);
}

View File

@ -3,25 +3,15 @@ import Footer from "@/components/global/Footer";
import MobileSidebar from "@/components/global/MobileSidebar";
import ModeToggle from "@/components/global/ModeToggle";
import Sidebar from "@/components/global/Sidebar";
import type { ReactNode } from "react";
// Main admin layout with sidebar, header actions, and footer.
export default function AdminLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
// <div className="flex flex-col min-h-screen min-w-screen">
// <header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4 py-2">
// <Header />
// </header>
// <main className="container mx-auto px-4 py-8">
// {children}
// </main>
// <footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
// <Footer />
// </footer>
// <Toaster />
// </div>
<div className="min-h-screen w-full">
<div className="flex min-h-screen w-full">
<aside className="hidden md:flex md:w-64 md:flex-col md:border-r md:bg-background">

View File

@ -6,14 +6,7 @@ import { StatusPill } from "@/components/home/StatusPill";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function fmtDate(d: Date) {
return new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
// Admin dashboard summary page.
export default async function HomePage() {
const data = await getAdminDashboard();
@ -28,9 +21,6 @@ export default async function HomePage() {
</div>
<div className="flex flex-wrap gap-2">
{/* <Button asChild variant="secondary">
<Link href="/artworks/new">Add artwork</Link>
</Button> */}
<Button asChild>
<Link href="/commissions/requests">Review requests</Link>
</Button>
@ -80,48 +70,6 @@ export default async function HomePage() {
</section>
<section className="grid gap-4 lg:grid-cols-3">
{/* Artwork status */}
{/* <Card>
<CardHeader>
<CardTitle className="text-base">Artwork status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<StatusPill label="Published" value={data.artworks.published} />
<StatusPill label="Unpublished" value={data.artworks.unpublished} />
<StatusPill label="NSFW" value={data.artworks.nsfw} />
<StatusPill label="Set as header" value={data.artworks.header} />
</CardContent>
</Card> */}
{/* Color pipeline */}
{/* <Card>
<CardHeader>
<CardTitle className="text-base">Color pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<StatusPill
label="Pending"
value={data.artworks.colorStatus.PENDING}
/>
<StatusPill
label="Processing"
value={data.artworks.colorStatus.PROCESSING}
/>
<StatusPill
label="Ready"
value={data.artworks.colorStatus.READY}
/>
<StatusPill
label="Failed"
value={data.artworks.colorStatus.FAILED}
/>
<div className="pt-2 text-sm text-muted-foreground">
Tip: keep “Failed” near zero—those typically need a re-run or file
fix.
</div>
</CardContent>
</Card> */}
{/* Commissions status */}
<Card>
<CardHeader>
@ -153,84 +101,6 @@ export default async function HomePage() {
</CardContent>
</Card>
</section>
{/* Recent activity */}
{/* <section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent artworks</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/artworks">Open</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{data.artworks.recent.length === 0 ? (
<div className="text-sm text-muted-foreground">No artworks yet.</div>
) : (
<ul className="space-y-2">
{data.artworks.recent.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-medium">{a.name}</div>
<div className="text-xs text-muted-foreground">
{fmtDate(a.createdAt)} · {a.colorStatus}
{a.published ? " · published" : " · draft"}
{a.needsWork ? " · needs work" : ""}
</div>
</div>
<Button asChild variant="secondary" size="sm">
<Link href={`/artworks/${a.slug}`}>Open</Link>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent commission requests</CardTitle>
<Button asChild variant="ghost" size="sm">
<Link href="/commissions/requests">Open</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{data.commissions.recent.length === 0 ? (
<div className="text-sm text-muted-foreground">
No commission requests yet.
</div>
) : (
<ul className="space-y-2">
{data.commissions.recent.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-medium">
{r.customerName}{" "}
<span className="text-muted-foreground">
({r.customerEmail})
</span>
</div>
<div className="text-xs text-muted-foreground">
{fmtDate(r.createdAt)} · {r.status}
</div>
</div>
<Button asChild variant="secondary" size="sm">
<Link href={`/commissions/requests/${r.id}`}>Open</Link>
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</section> */}
</div>
);
}

View File

@ -1,6 +1,7 @@
import EditTagForm from "@/components/tags/EditTagForm";
import { prisma } from "@/lib/prisma";
// Edit tag page.
export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const tag = await prisma.tag.findUnique({
@ -15,8 +16,8 @@ export default async function PortfolioTagsEditPage({ params }: { params: { id:
},
},
aliases: true
}
})
},
});
const categories = await prisma.artCategory.findMany({
include: { tagLinks: true },

View File

@ -1,6 +1,7 @@
import NewTagForm from "@/components/tags/NewTagForm";
import { prisma } from "@/lib/prisma";
// Create a new tag page.
export default async function PortfolioTagsNewPage() {
const categories = await prisma.artCategory.findMany({
include: { tagLinks: true },

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
// Admin tags management page.
export default async function ArtTagsPage() {
const items = await prisma.tag.findMany({
include: {

View File

@ -1,6 +1,7 @@
import { getLatestTos } from "@/actions/tos/getTos";
import TosEditor from "@/components/tos/Editor";
// Admin page for editing Terms of Service.
export default async function TosPage() {
const markdown = await getLatestTos();
@ -14,4 +15,4 @@ export default async function TosPage() {
</div>
</div>
);
}
}

View File

@ -1,7 +1,10 @@
import UploadBulkImageForm from "@/components/uploads/UploadBulkImageForm";
// Bulk image upload page.
export default function UploadsBulkPage() {
return (
<div><UploadBulkImageForm /></div>
<div>
<UploadBulkImageForm />
</div>
);
}
}

View File

@ -1,7 +1,10 @@
import UploadImageForm from "@/components/uploads/UploadImageForm";
// Single image upload page.
export default function UploadsSinglePage() {
return (
<div><UploadImageForm /></div>
<div>
<UploadImageForm />
</div>
);
}
}

View File

@ -1,11 +1,13 @@
import { CreateUserForm } from "@/components/users/CreateUserForm";
import { auth } from "@/lib/auth";
import type { SessionWithRole } from "@/types/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
// Admin-only user creation page.
export default async function NewUserPage() {
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role;
const role = (session as SessionWithRole)?.user?.role;
if (!session) redirect("/login");
if (role !== "admin") redirect("/");

View File

@ -1,11 +1,13 @@
import { UsersTable } from "@/components/users/UsersTable";
import { auth } from "@/lib/auth";
import type { SessionWithRole } from "@/types/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
// Admin users list page.
export default async function UsersPage() {
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role as string | undefined;
const role = (session as SessionWithRole)?.user?.role;
if (!session) redirect("/login");
if (role !== "admin") redirect("/");

View File

@ -1,5 +1,6 @@
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
// Forgot password page.
export default function ForgotPasswordPage() {
return (
<div className="mx-auto max-w-md p-6">

View File

@ -1,8 +1,10 @@
import type { ReactNode } from "react";
// Layout wrapper for auth routes.
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
<div className="flex flex-col min-h-screen min-w-screen">

View File

@ -1,6 +1,7 @@
import LoginForm from "@/components/auth/LoginForm";
import { Suspense } from "react";
// Admin login page.
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
@ -20,4 +21,4 @@ export default function LoginPage() {
</div>
</div>
);
}
}

View File

@ -2,6 +2,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
// One-time admin registration page (only when no users exist).
export default async function RegisterPage() {
const count = await prisma.user.count();
if (count !== 0) redirect("/login");

View File

@ -1,6 +1,7 @@
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
import Link from "next/link";
// Reset password page, expects a token query param.
export default async function ResetPasswordPage({
searchParams,
}: {
@ -13,7 +14,7 @@ export default async function ResetPasswordPage({
<div className="mx-auto max-w-md p-6">
<p>No valid token, please try again or get back to <Link href="/">Home</Link></p>
</div>
)
);
}
return (

View File

@ -1,6 +1,7 @@
import { getArtworksPage } from "@/lib/queryArtworks";
import { NextResponse, type NextRequest } from "next/server";
// Public API for paginated artworks listing.
export async function GET(req: NextRequest) {
const publishedParam = req.nextUrl.searchParams.get("published") ?? "all";

View File

@ -1,4 +1,5 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
// Better Auth route handlers.
export const { POST, GET } = toNextJsHandler(auth);

View File

@ -1,10 +1,27 @@
import { s3 } from "@/lib/s3";
import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import type { NextRequest } from "next/server";
import { Readable } from "stream";
function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function";
}
function toBodyInit(body: S3Body): BodyInit {
if (body instanceof Readable) {
return Readable.toWeb(body) as ReadableStream<Uint8Array>;
}
if (isWebReadableStream(body)) {
return body;
}
return body as BodyInit;
}
// Streams images from S3 for the admin app.
export async function GET(_req: NextRequest, context: { params: Promise<{ key: string[] }> }) {
const { key } = await context.params;
const s3Key = key.join("/");
const s3Key = key.join("/");
try {
const command = new GetObjectCommand({
@ -20,7 +37,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
const contentType = response.ContentType ?? "application/octet-stream";
return new Response(response.Body as ReadableStream, {
return new Response(toBodyInit(response.Body as S3Body), {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
@ -28,7 +45,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
},
});
} catch (err) {
console.log(err)
console.error(err);
return new Response("Image not found", { status: 404 });
}
}
}

View File

@ -1,9 +1,12 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import archiver from "archiver";
import { NextRequest } from "next/server";
import type { NextRequest } from "next/server";
import { Readable } from "stream";
// Streams commission request files (single or zip) from S3.
type Mode = "display" | "download" | "bulk";
function contentDisposition(filename: string, mode: Mode) {
@ -17,6 +20,20 @@ function sanitizeZipEntryName(name: string) {
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 180);
}
function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return !!value && typeof (value as ReadableStream<Uint8Array>).getReader === "function";
}
function toBodyInit(body: S3Body): BodyInit {
if (body instanceof Readable) {
return Readable.toWeb(body) as ReadableStream<Uint8Array>;
}
if (isWebReadableStream(body)) {
return body;
}
return body as BodyInit;
}
export async function GET(req: NextRequest) {
try {
const bucket = process.env.BUCKET_NAME;
@ -52,7 +69,7 @@ export async function GET(req: NextRequest) {
const contentType = file.fileType || s3Res.ContentType || "application/octet-stream";
return new Response(s3Res.Body as ReadableStream, {
return new Response(toBodyInit(s3Res.Body as S3Body), {
headers: {
"Content-Type": contentType,
// You can tune caching; admin-only content usually should be private.
@ -117,8 +134,17 @@ export async function GET(req: NextRequest) {
f.originalFile || f.fileKey.split("/").pop() || "file"
);
// obj.Body is a Node stream in Node runtime; works with archiver
archive.append(obj.Body as any, { name: entryName });
// obj.Body can be a Node Readable, web ReadableStream, or Buffer.
const body = obj.Body;
if (!body) continue;
if (body instanceof Readable) {
archive.append(body, { name: entryName });
} else if (isWebReadableStream(body)) {
archive.append(Readable.from(body as AsyncIterable<Uint8Array>), { name: entryName });
} else {
archive.append(body as Buffer, { name: entryName });
}
}
await archive.finalize();

View File

@ -1,38 +1,11 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { publicCommissionRequestSchema } from "@/schemas/commissions/publicRequest";
import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod/v4";
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
customCardId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
extraIds: z.array(z.string().min(1)).default([]),
customerName: z.string().min(1).max(200),
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
}).superRefine((data, ctx) => {
const hasType = Boolean(data.typeId);
const hasCustom = Boolean(data.customCardId);
if (!hasType && !hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Missing commission type or custom card",
});
}
if (hasType && hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Only one of typeId or customCardId is allowed",
});
}
});
// Public API endpoint for commission submissions (multipart form).
function safeJsonParse(input: string) {
try {
@ -64,7 +37,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
}
const payload = payloadSchema.safeParse(parsedJson);
const payload = publicCommissionRequestSchema.safeParse(parsedJson);
if (!payload.success) {
return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues },

View File

@ -2,6 +2,7 @@
import { useEffect } from "react";
// Global error UI for the app router segment.
export default function Error({
error,
reset,

View File

@ -1,5 +1,6 @@
"use client";
// Root-level error boundary UI.
export default function GlobalError({
error,
reset,

View File

@ -1,11 +1,13 @@
export const dynamic = "force-dynamic";
export const revalidate = 0;
import { ThemeProvider } from "@/components/global/ThemeProvider";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { ReactNode } from "react";
import "./globals.css";
// Root layout and metadata for the admin app.
export const dynamic = "force-dynamic";
export const revalidate = 0;
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@ -24,7 +26,7 @@ export const metadata: Metadata = {
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>

View File

@ -1,3 +1,4 @@
// Global loading state UI.
export default function Loading() {
return (
<main className="min-h-dvh flex items-center justify-center">

View File

@ -1,5 +1,6 @@
import Link from "next/link";
// 404 page for missing routes.
export default function NotFound() {
return (
<main className="min-h-dvh flex items-center justify-center px-6">