Refactor code
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("/");
|
||||
|
||||
@ -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("/");
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Global error UI for the app router segment.
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// Root-level error boundary UI.
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Global loading state UI.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-dvh flex items-center justify-center">
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user