5 Commits

Author SHA1 Message Date
d75501860d Refactor Artwork list table 2026-02-04 00:23:50 +01:00
ff886d3002 Fix type error 2026-02-03 16:42:13 +01:00
48c7d522c1 Refactor code 2026-02-03 13:26:47 +01:00
531bb8750e Refactor code 2026-02-03 13:12:31 +01:00
8572e22c5d Refactor code 2026-02-03 12:17:47 +01:00
190 changed files with 1355 additions and 1537 deletions

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import { DeleteObjectCommand } from "@aws-sdk/client-s3"; import { DeleteObjectCommand } from "@aws-sdk/client-s3";
// Deletes an artwork and all related assets and records.
export async function deleteArtwork(artworkId: string) { export async function deleteArtwork(artworkId: string) {
const artwork = await prisma.artwork.findUnique({ const artwork = await prisma.artwork.findUnique({
where: { id: artworkId }, where: { id: artworkId },

View File

@ -54,12 +54,12 @@ function centroidFromPaletteHexes(
}; };
const fallbackHex = const fallbackHex =
hexByType["Vibrant"] || hexByType.Vibrant ||
hexByType["Muted"] || hexByType.Muted ||
hexByType["DarkVibrant"] || hexByType.DarkVibrant ||
hexByType["DarkMuted"] || hexByType.DarkMuted ||
hexByType["LightVibrant"] || hexByType.LightVibrant ||
hexByType["LightMuted"]; hexByType.LightMuted;
let L = 0, let L = 0,
A = 0, A = 0,
@ -123,7 +123,9 @@ export async function generateArtworkColorsForArtwork(artworkId: string) {
for (const { type, hex } of vibrantHexes) { for (const { type, hex } of vibrantHexes) {
if (!hex) continue; if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); const match = hex.match(/\w\w/g);
if (!match) continue;
const [r, g, b] = match.map((h) => parseInt(h, 16));
const name = generateColorName(hex); const name = generateColorName(hex);
const color = await prisma.color.upsert({ const color = await prisma.color.upsert({

View File

@ -8,6 +8,7 @@ import sharp from "sharp";
const GALLERY_TARGET_SIZE = 300; const GALLERY_TARGET_SIZE = 300;
// Generates a gallery-sized variant for a single artwork.
export async function generateGalleryVariant( export async function generateGalleryVariant(
artworkId: string, artworkId: string,
opts?: { force?: boolean }, opts?: { force?: boolean },

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Returns color swatches for a given artwork.
export async function getArtworkColors(artworkId: string) { export async function getArtworkColors(artworkId: string) {
return prisma.artworkColor.findMany({ return prisma.artworkColor.findMany({
where: { artworkId }, where: { artworkId },

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Returns album/category options for artwork filters.
export async function getArtworkFilterOptions() { export async function getArtworkFilterOptions() {
const [albums, categories] = await Promise.all([ const [albums, categories] = await Promise.all([
prisma.album.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), prisma.album.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),

View File

@ -1,9 +1,10 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
// Loads a single artwork with relations for the edit page.
export async function getSingleArtwork(id: string) { export async function getSingleArtwork(id: string) {
return await prisma.artwork.findUnique({ return prisma.artwork.findUnique({
where: { id }, where: { id },
include: { include: {
// album: true, // album: true,
@ -17,7 +18,7 @@ export async function getSingleArtwork(id: string) {
// sortContexts: true, // sortContexts: true,
tags: true, tags: true,
variants: true, variants: true,
timelapse: true timelapse: true,
} },
}) });
} }

View File

@ -1,15 +1,19 @@
"use server"; "use server";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { ArtworkTableInput, artworkTableInputSchema, artworkTableOutputSchema } from "@/schemas/artworks/tableSchema"; import { type ArtworkTableInput, artworkTableInputSchema, artworkTableOutputSchema } from "@/schemas/artworks/tableSchema";
// Builds the admin artworks table page with filters, sorting, and pagination.
function triToBool(tri: "any" | "true" | "false"): boolean | undefined { function triToBool(tri: "any" | "true" | "false"): boolean | undefined {
if (tri === "any") return undefined; if (tri === "any") return undefined;
return tri === "true"; return tri === "true";
} }
function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) { function mapSortingToOrderBy(
const allowed: Record<string, (desc: boolean) => any> = { sorting: ArtworkTableInput["sorting"]
): Prisma.ArtworkOrderByWithRelationInput[] {
const allowed: Record<string, (desc: boolean) => Prisma.ArtworkOrderByWithRelationInput> = {
createdAt: (desc) => ({ createdAt: desc ? "desc" : "asc" }), createdAt: (desc) => ({ createdAt: desc ? "desc" : "asc" }),
updatedAt: (desc) => ({ updatedAt: desc ? "desc" : "asc" }), updatedAt: (desc) => ({ updatedAt: desc ? "desc" : "asc" }),
sortIndex: (desc) => ({ sortIndex: desc ? "desc" : "asc" }), sortIndex: (desc) => ({ sortIndex: desc ? "desc" : "asc" }),
@ -25,9 +29,10 @@ function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) {
tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }), tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }),
}; };
const orderBy = sorting const orderBy = sorting.flatMap((s) => {
.map((s) => allowed[s.id]?.(s.desc)) const mapper = allowed[s.id];
.filter(Boolean); return mapper ? [mapper(s.desc)] : [];
});
orderBy.push({ id: "desc" }); orderBy.push({ id: "desc" });
return orderBy; return orderBy;
@ -44,7 +49,7 @@ export async function getArtworksTablePage(input: unknown) {
const nsfw = triToBool(filters.nsfw); const nsfw = triToBool(filters.nsfw);
const needsWork = triToBool(filters.needsWork); const needsWork = triToBool(filters.needsWork);
const where: any = { const where: Prisma.ArtworkWhereInput = {
...(typeof published === "boolean" ? { published } : {}), ...(typeof published === "boolean" ? { published } : {}),
...(typeof nsfw === "boolean" ? { nsfw } : {}), ...(typeof nsfw === "boolean" ? { nsfw } : {}),
...(typeof needsWork === "boolean" ? { needsWork } : {}), ...(typeof needsWork === "boolean" ? { needsWork } : {}),

View File

@ -1,13 +1,9 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { GalleryVariantStats } from "@/types/Artwork";
export type GalleryVariantStats = { // Returns counts for gallery variant presence.
total: number;
withGallery: number;
missing: number;
};
export async function getGalleryVariantStats(): Promise<GalleryVariantStats> { export async function getGalleryVariantStats(): Promise<GalleryVariantStats> {
const [total, withGallery] = await Promise.all([ const [total, withGallery] = await Promise.all([
prisma.artwork.count(), prisma.artwork.count(),

View File

@ -2,25 +2,30 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import {
confirmArtworkTimelapseUploadSchema,
createArtworkTimelapseUploadSchema,
deleteArtworkTimelapseSchema,
setArtworkTimelapseEnabledSchema,
} from "@/schemas/artworks/timelapse";
import type {
ConfirmArtworkTimelapseUploadInput,
CreateArtworkTimelapseUploadInput,
DeleteArtworkTimelapseInput,
SetArtworkTimelapseEnabledInput,
} from "@/schemas/artworks/timelapse";
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
const createUploadSchema = z.object({
artworkId: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** /**
* Creates a presigned PUT url so the client can upload large timelapse videos directly to S3 * Creates a presigned PUT url so the client can upload large timelapse videos directly to S3
* (avoids Next.js body-size/proxy limits). * (avoids Next.js body-size/proxy limits).
*/ */
export async function createArtworkTimelapseUpload(input: z.infer<typeof createUploadSchema>) { export async function createArtworkTimelapseUpload(input: CreateArtworkTimelapseUploadInput) {
const { artworkId, fileName, mimeType, sizeBytes } = createUploadSchema.parse(input); const { artworkId, fileName, mimeType, sizeBytes } =
createArtworkTimelapseUploadSchema.parse(input);
const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined; const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined;
const suffix = ext ? `.${ext}` : ""; const suffix = ext ? `.${ext}` : "";
@ -41,17 +46,10 @@ export async function createArtworkTimelapseUpload(input: z.infer<typeof createU
return { uploadUrl, s3Key, fileName, mimeType, sizeBytes }; return { uploadUrl, s3Key, fileName, mimeType, sizeBytes };
} }
const confirmSchema = z.object({
artworkId: z.string().min(1),
s3Key: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** Persist uploaded timelapse metadata in DB (upsert by artworkId). */ /** Persist uploaded timelapse metadata in DB (upsert by artworkId). */
export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confirmSchema>) { export async function confirmArtworkTimelapseUpload(input: ConfirmArtworkTimelapseUploadInput) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } = confirmSchema.parse(input); const { artworkId, s3Key, fileName, mimeType, sizeBytes } =
confirmArtworkTimelapseUploadSchema.parse(input);
// If an old timelapse exists, delete the old object so you don't leak storage. // If an old timelapse exists, delete the old object so you don't leak storage.
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } }); const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
@ -91,13 +89,8 @@ export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confir
return { ok: true }; return { ok: true };
} }
const enabledSchema = z.object({ export async function setArtworkTimelapseEnabled(input: SetArtworkTimelapseEnabledInput) {
artworkId: z.string().min(1), const { artworkId, enabled } = setArtworkTimelapseEnabledSchema.parse(input);
enabled: z.boolean(),
});
export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSchema>) {
const { artworkId, enabled } = enabledSchema.parse(input);
await prisma.artworkTimelapse.update({ await prisma.artworkTimelapse.update({
where: { artworkId }, where: { artworkId },
@ -108,12 +101,8 @@ export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSc
return { ok: true }; return { ok: true };
} }
const deleteSchema = z.object({ export async function deleteArtworkTimelapse(input: DeleteArtworkTimelapseInput) {
artworkId: z.string().min(1), const { artworkId } = deleteArtworkTimelapseSchema.parse(input);
});
export async function deleteArtworkTimelapse(input: z.infer<typeof deleteSchema>) {
const { artworkId } = deleteSchema.parse(input);
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } }); const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (!existing) return { ok: true }; if (!existing) return { ok: true };

View File

@ -1,10 +1,11 @@
"use server" "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { artworkSchema } from "@/schemas/artworks/imageSchema"; import { artworkSchema } from "@/schemas/artworks/imageSchema";
import { normalizeNames, slugify } from "@/utils/artworkHelpers"; import { normalizeNames, slugify } from "@/utils/artworkHelpers";
import { z } from "zod/v4"; import type { z } from "zod/v4";
// Updates an artwork and its tag/category relationships.
export async function updateArtwork( export async function updateArtwork(
values: z.infer<typeof artworkSchema>, values: z.infer<typeof artworkSchema>,
id: string id: string
@ -36,12 +37,11 @@ export async function updateArtwork(
const categoriesToCreate = normalizeNames(newCategoryNames); const categoriesToCreate = normalizeNames(newCategoryNames);
const updatedArtwork = await prisma.$transaction(async (tx) => { const updatedArtwork = await prisma.$transaction(async (tx) => {
if (setAsHeader) {
if(setAsHeader) {
await tx.artwork.updateMany({ await tx.artwork.updateMany({
where: { setAsHeader: true }, where: { setAsHeader: true },
data: { setAsHeader: false }, data: { setAsHeader: false },
}) });
} }
const tagsRelation = const tagsRelation =
@ -73,7 +73,7 @@ export async function updateArtwork(
: {}; : {};
return tx.artwork.update({ return tx.artwork.update({
where: { id: id }, where: { id },
data: { data: {
name, name,
slug: slugify(name), slug: slugify(name),

View File

@ -2,28 +2,25 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { z } from "zod/v4"; import type { RegisterFirstUserInput } from "@/schemas/auth";
import { registerFirstUserSchema } from "@/schemas/auth";
import type { SignUpResponse } from "@/types/auth";
const schema = z.object({ // Registers the very first user and upgrades them to admin.
name: z.string().min(1).max(200), export async function registerFirstUser(input: RegisterFirstUserInput) {
email: z.string().email().max(320),
password: z.string().min(8).max(128),
});
export async function registerFirstUser(input: z.infer<typeof schema>) {
const count = await prisma.user.count(); const count = await prisma.user.count();
if (count !== 0) throw new Error("Registration is disabled."); if (count !== 0) throw new Error("Registration is disabled.");
const { name, email, password } = schema.parse(input); const { name, email, password } = registerFirstUserSchema.parse(input);
const res = await auth.api.signUpEmail({ const res = (await auth.api.signUpEmail({
body: { name, email, password }, body: { name, email, password },
}); })) as SignUpResponse;
const userId = const userId =
(res as any)?.user?.id ?? res.user?.id ??
(res as any)?.data?.user?.id ?? res.data?.user?.id ??
(res as any)?.data?.id; res.data?.id;
if (!userId) throw new Error("Signup failed: no user id returned."); if (!userId) throw new Error("Signup failed: no user id returned.");

View File

@ -1,25 +1,27 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
import { categorySchema } from "@/schemas/artworks/categorySchema" import { categorySchema } from "@/schemas/artworks/categorySchema";
import type * as z from "zod/v4";
export async function createCategory(formData: categorySchema) { // Creates a new artwork category.
const parsed = categorySchema.safeParse(formData) export async function createCategory(formData: z.infer<typeof categorySchema>) {
const parsed = categorySchema.safeParse(formData);
if (!parsed.success) { if (!parsed.success) {
console.error("Validation failed", parsed.error) console.error("Validation failed", parsed.error);
throw new Error("Invalid input") throw new Error("Invalid input");
} }
const data = parsed.data const data = parsed.data;
const created = await prisma.artCategory.create({ const created = await prisma.artCategory.create({
data: { data: {
name: data.name, name: data.name,
slug: data.slug, slug: data.slug,
description: data.description description: data.description,
}, },
}) });
return created return created;
} }

View File

@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
// Deletes a category if it is not referenced by artworks or tags.
export async function deleteCategory(catId: string) { export async function deleteCategory(catId: string) {
const cat = await prisma.artCategory.findUnique({ const cat = await prisma.artCategory.findUnique({
where: { id: catId }, where: { id: catId },
@ -11,7 +12,7 @@ export async function deleteCategory(catId: string) {
_count: { _count: {
select: { select: {
tagLinks: true, tagLinks: true,
artworks: true artworks: true,
}, },
}, },
}, },

View File

@ -1,19 +1,20 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
// Category data fetchers for admin pages.
export async function getCategoriesWithTags() { export async function getCategoriesWithTags() {
return await prisma.artCategory.findMany({ return prisma.artCategory.findMany({
include: { tagLinks: { include: { tag: true } } }, include: { tagLinks: { include: { tag: true } } },
orderBy: { sortIndex: "asc" }, orderBy: { sortIndex: "asc" },
}) });
} }
export async function getCategoriesWithCount() { export async function getCategoriesWithCount() {
return await prisma.artCategory.findMany({ return prisma.artCategory.findMany({
include: { include: {
_count: { select: { artworks: true, tagLinks: true } }, _count: { select: { artworks: true, tagLinks: true } },
}, },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}) });
} }

View File

@ -1,27 +1,28 @@
"use server" "use server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { categorySchema } from '@/schemas/artworks/categorySchema'; import { categorySchema } from "@/schemas/artworks/categorySchema";
import { z } from 'zod/v4'; import type * as z from "zod/v4";
// Updates an artwork category by id.
export async function updateCategory(id: string, rawData: z.infer<typeof categorySchema>) { export async function updateCategory(id: string, rawData: z.infer<typeof categorySchema>) {
const parsed = categorySchema.safeParse(rawData) const parsed = categorySchema.safeParse(rawData);
if (!parsed.success) { if (!parsed.success) {
console.error("Validation failed", parsed.error) console.error("Validation failed", parsed.error);
throw new Error("Invalid input") throw new Error("Invalid input");
} }
const data = parsed.data const data = parsed.data;
const updated = await prisma.artCategory.update({ const updated = await prisma.artCategory.update({
where: { id }, where: { id },
data: { data: {
name: data.name, name: data.name,
slug: data.slug, slug: data.slug,
description: data.description description: data.description,
}, },
}) });
return updated return updated;
} }

View File

@ -1,16 +1,9 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { ArtworkColorStats } from "@/types/colors";
export type ArtworkColorStats = { // Aggregates color-processing status counts for artworks.
total: number;
ready: number;
pending: number;
processing: number;
failed: number;
missingSortKey: number;
};
export async function getArtworkColorStats(): Promise<ArtworkColorStats> { export async function getArtworkColorStats(): Promise<ArtworkColorStats> {
const [ const [
total, total,

View File

@ -3,15 +3,9 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors"; import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
import type { ProcessColorsResult } from "@/types/colors";
export type ProcessColorsResult = { // Processes pending/failed artwork colors with a configurable batch size.
picked: number;
processed: number;
ok: number;
failed: number;
results: Array<{ artworkId: string; ok: boolean; error?: string }>;
};
export async function processPendingArtworkColors(args?: { export async function processPendingArtworkColors(args?: {
limit?: number; limit?: number;
includeFailed?: boolean; includeFailed?: boolean;

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Deletes a custom commission card by id.
export async function deleteCommissionCustomCard(id: string) { export async function deleteCommissionCustomCard(id: string) {
await prisma.commissionCustomCard.delete({ await prisma.commissionCustomCard.delete({
where: { id }, where: { id },

View File

@ -1,6 +1,7 @@
"use server"; "use server";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import type { CommissionCustomCardImageItem } from "@/types/commissions";
import { import {
DeleteObjectCommand, DeleteObjectCommand,
ListObjectsV2Command, ListObjectsV2Command,
@ -9,13 +10,6 @@ import {
const PREFIX = "commissions/custom-cards/"; const PREFIX = "commissions/custom-cards/";
export type CommissionCustomCardImageItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) { function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`; return `/api/image/${encodeURI(key)}`;
} }

View File

@ -6,6 +6,7 @@ import {
type CommissionCustomCardValues, type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard"; } from "@/schemas/commissionCustomCard";
// Creates a new custom commission card with options/extras.
export async function createCommissionCustomCard( export async function createCommissionCustomCard(
formData: CommissionCustomCardValues formData: CommissionCustomCardValues
) { ) {

View File

@ -6,6 +6,7 @@ import {
type CommissionCustomCardValues, type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard"; } from "@/schemas/commissionCustomCard";
// Updates a custom commission card and resets related options/extras.
export async function updateCommissionCustomCard( export async function updateCommissionCustomCard(
id: string, id: string,
rawData: CommissionCustomCardValues rawData: CommissionCustomCardValues

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Updates sort order for custom commission cards.
export async function updateCommissionCustomCardSortOrder( export async function updateCommissionCustomCardSortOrder(
items: { id: string; sortIndex: number }[] items: { id: string; sortIndex: number }[]
) { ) {

View File

@ -1,6 +1,7 @@
"use server"; "use server";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import type { CommissionExampleItem } from "@/types/commissions";
import { import {
DeleteObjectCommand, DeleteObjectCommand,
ListObjectsV2Command, ListObjectsV2Command,
@ -9,13 +10,6 @@ import {
const PREFIX = "commissions/examples/"; const PREFIX = "commissions/examples/";
export type CommissionExampleItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) { function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`; return `/api/image/${encodeURI(key)}`;
} }

View File

@ -1,7 +1,8 @@
'use server'; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Returns the latest active commission guidelines (markdown + example image).
export async function getActiveGuidelines(): Promise<{ export async function getActiveGuidelines(): Promise<{
markdown: string | null; markdown: string | null;
exampleImageUrl: string | null; exampleImageUrl: string | null;

View File

@ -1,7 +1,8 @@
'use server'; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Deactivates existing guidelines and creates a new active version.
export async function saveGuidelines(markdown: string, exampleImageUrl: string | null) { export async function saveGuidelines(markdown: string, exampleImageUrl: string | null) {
await prisma.commissionGuidelines.updateMany({ await prisma.commissionGuidelines.updateMany({
where: { isActive: true }, where: { isActive: true },

View File

@ -1,8 +1,9 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { z } from "zod"; import { z } from "zod/v4";
// Deletes a commission request by id.
export async function deleteCommissionRequest(id: string) { export async function deleteCommissionRequest(id: string) {
const parsed = z.string().min(1).parse(id); const parsed = z.string().min(1).parse(id);

View File

@ -1,8 +1,9 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { calculatePriceRange, PriceSource } from "@/utils/commissionPricing"; import { calculatePriceRange, type PriceSource } from "@/utils/commissionPricing";
// Loads a commission request with related data and computed price estimate.
export async function getCommissionRequestById(id: string) { export async function getCommissionRequestById(id: string) {
const req = await prisma.commissionRequest.findUnique({ const req = await prisma.commissionRequest.findUnique({
where: { id }, where: { id },

View File

@ -1,39 +1,24 @@
"use server"; "use server";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { import { commissionRequestTableRowSchema } from "@/schemas/commissions/requests";
commissionRequestTableRowSchema, import type {
commissionStatusSchema, CommissionRequestsTableFilters,
} from "@/schemas/commissions/requests"; CommissionRequestsTableSorting,
import { z } from "zod"; } from "@/schemas/commissions/requestsTable";
import type { CursorPagination } from "@/types/pagination";
export type CursorPagination = { pageIndex: number; pageSize: number }; import { z } from "zod/v4";
export type SortDir = "asc" | "desc";
const triStateSchema = z.enum(["any", "true", "false"]);
const sortingSchema = z.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
);
const filtersSchema = z.object({
q: z.string().optional(),
email: z.string().optional(),
status: z.union([z.literal("any"), commissionStatusSchema]).default("any"),
hasFiles: triStateSchema.default("any"),
});
// Builds a paginated, filtered, and sorted commission-requests table payload for the admin UI.
export async function getCommissionRequestsTablePage(input: { export async function getCommissionRequestsTablePage(input: {
pagination: CursorPagination; pagination: CursorPagination;
sorting: z.infer<typeof sortingSchema>; sorting: CommissionRequestsTableSorting;
filters: z.infer<typeof filtersSchema>; filters: CommissionRequestsTableFilters;
}) { }) {
const { pagination, sorting, filters } = input; const { pagination, sorting, filters } = input;
const where: any = {}; const where: Prisma.CommissionRequestWhereInput = {};
if (filters.q) { if (filters.q) {
const q = filters.q.trim(); const q = filters.q.trim();
@ -60,7 +45,7 @@ export async function getCommissionRequestsTablePage(input: {
// sorting // sorting
const sort = sorting?.[0] ?? { id: "createdAt", desc: true }; const sort = sorting?.[0] ?? { id: "createdAt", desc: true };
const orderBy: any = const orderBy: Prisma.CommissionRequestOrderByWithRelationInput =
sort.id === "createdAt" sort.id === "createdAt"
? { createdAt: sort.desc ? "desc" : "asc" } ? { createdAt: sort.desc ? "desc" : "asc" }
: sort.id === "status" : sort.id === "status"
@ -94,7 +79,7 @@ export async function getCommissionRequestsTablePage(input: {
customerName: r.customerName, customerName: r.customerName,
customerEmail: r.customerEmail, customerEmail: r.customerEmail,
customerSocials: r.customerSocials ?? null, customerSocials: r.customerSocials ?? null,
status: r.status as any, status: r.status,
fileCount: r._count.files, fileCount: r._count.files,
})); }));

View File

@ -2,8 +2,9 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/requests"; import { commissionStatusSchema } from "@/schemas/commissions/requests";
import { z } from "zod"; import { z } from "zod/v4";
// Sets a commission request status (admin action).
export async function setCommissionRequestStatus(input: { export async function setCommissionRequestStatus(input: {
id: string; id: string;
status: z.infer<typeof commissionStatusSchema>; status: z.infer<typeof commissionStatusSchema>;

View File

@ -1,20 +1,14 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/requests"; import {
import { z } from "zod/v4"; updateCommissionRequestSchema,
} from "@/schemas/commissions/updateRequest";
import type { UpdateCommissionRequestInput } from "@/schemas/commissions/updateRequest";
const updateSchema = z.object({ // Updates editable fields on a commission request.
id: z.string().min(1), export async function updateCommissionRequest(input: UpdateCommissionRequestInput) {
status: commissionStatusSchema, const data = updateCommissionRequestSchema.parse(input);
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),
});
export async function updateCommissionRequest(input: z.infer<typeof updateSchema>) {
const data = updateSchema.parse(input);
await prisma.commissionRequest.update({ await prisma.commissionRequest.update({
where: { id: data.id }, where: { id: data.id },

View File

@ -1,21 +1,18 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod";
import { COMMISSION_STATUSES } from "@/lib/commissions/kanban"; import { prisma } from "@/lib/prisma";
import { prisma } from "@/lib/prisma"; // adjust to your prisma import import {
// import { requireAdmin } from "@/lib/auth/requireAdmin"; // recommended if you have it updateCommissionRequestStatusSchema,
} from "@/schemas/commissions/updateRequestStatus";
import type { UpdateCommissionRequestStatusInput } from "@/schemas/commissions/updateRequestStatus";
const schema = z.object({ // Updates a commission request status and revalidates the kanban page.
id: z.string().min(1), export async function updateCommissionRequestStatus(
status: z.enum(COMMISSION_STATUSES), input: UpdateCommissionRequestStatusInput
}); ) {
const { id, status } = updateCommissionRequestStatusSchema.parse(input);
export async function updateCommissionRequestStatus(input: z.infer<typeof schema>) {
// await requireAdmin(); // enforce auth/role check here
const { id, status } = schema.parse(input);
await prisma.commissionRequest.update({ await prisma.commissionRequest.update({
where: { id }, where: { id },
@ -23,5 +20,5 @@ export async function updateCommissionRequestStatus(input: z.infer<typeof schema
}); });
// revalidate the board page so a refresh always reflects server truth // revalidate the board page so a refresh always reflects server truth
revalidatePath("/commissions/board"); revalidatePath("/commissions/kanban");
} }

View File

@ -1,19 +1,18 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
// Deletes a commission type and its link records.
export async function deleteCommissionType(typeId: string) { export async function deleteCommissionType(typeId: string) {
await prisma.commissionTypeOption.deleteMany({ await prisma.commissionTypeOption.deleteMany({
where: { typeId }, where: { typeId },
}) });
await prisma.commissionTypeExtra.deleteMany({ await prisma.commissionTypeExtra.deleteMany({
where: { typeId }, where: { typeId },
}) });
await prisma.commissionType.delete({ await prisma.commissionType.delete({
where: { id: typeId }, where: { id: typeId },
}) });
} }

View File

@ -4,8 +4,9 @@ import { prisma } from "@/lib/prisma";
import { commissionExtraSchema } from "@/schemas/commissionType"; import { commissionExtraSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/extras"; const LIST_PATH = "/commissions/types/extras";
// CRUD helpers for commission extras (admin-only pages).
export async function createCommissionExtra(input: unknown) { export async function createCommissionExtra(input: unknown) {
const data = commissionExtraSchema.parse(input); const data = commissionExtraSchema.parse(input);
const created = await prisma.commissionExtra.create({ data }); const created = await prisma.commissionExtra.create({ data });
@ -24,7 +25,6 @@ export async function deleteCommissionExtra(id: string) {
// Optional safety: // Optional safety:
// const used = await prisma.commissionTypeExtra.count({ where: { extraId: id } }); // const used = await prisma.commissionTypeExtra.count({ where: { extraId: id } });
// if (used > 0) throw new Error("Extra is linked to types."); // if (used > 0) throw new Error("Extra is linked to types.");
console.log("TBD"); await prisma.commissionExtra.delete({ where: { id } });
// await prisma.commissionExtra.delete({ where: { id } }); revalidatePath(LIST_PATH);
// revalidatePath(LIST_PATH);
} }

View File

@ -2,9 +2,11 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType"; import { commissionTypeSchema } from "@/schemas/commissionType";
import type * as z from "zod/v4";
// Creates a commission option entry.
export async function createCommissionOption(data: { name: string }) { export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({ return prisma.commissionOption.create({
data: { data: {
name: data.name, name: data.name,
description: "", description: "",
@ -12,8 +14,9 @@ export async function createCommissionOption(data: { name: string }) {
}); });
} }
// Creates a commission extra entry.
export async function createCommissionExtra(data: { name: string }) { export async function createCommissionExtra(data: { name: string }) {
return await prisma.commissionExtra.create({ return prisma.commissionExtra.create({
data: { data: {
name: data.name, name: data.name,
description: "", description: "",
@ -21,11 +24,12 @@ export async function createCommissionExtra(data: { name: string }) {
}); });
} }
// Creates a commission custom input entry.
export async function createCommissionCustomInput(data: { export async function createCommissionCustomInput(data: {
name: string; name: string;
fieldId: string; fieldId: string;
}) { }) {
return await prisma.commissionCustomInput.create({ return prisma.commissionCustomInput.create({
data: { data: {
name: data.name, name: data.name,
fieldId: data.fieldId, fieldId: data.fieldId,
@ -33,7 +37,10 @@ export async function createCommissionCustomInput(data: {
}); });
} }
export async function createCommissionType(formData: commissionTypeSchema) { // Creates a commission type with nested options/extras/custom inputs.
export async function createCommissionType(
formData: z.infer<typeof commissionTypeSchema>
) {
const parsed = commissionTypeSchema.safeParse(formData); const parsed = commissionTypeSchema.safeParse(formData);
if (!parsed.success) { if (!parsed.success) {

View File

@ -4,13 +4,9 @@ import { prisma } from "@/lib/prisma";
import { commissionOptionSchema } from "@/schemas/commissionType"; import { commissionOptionSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/options"; const LIST_PATH = "/commissions/types/options";
function toInt(v: string) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : 0;
}
// CRUD helpers for commission options (admin-only pages).
export async function createCommissionOption(input: unknown) { export async function createCommissionOption(input: unknown) {
const data = commissionOptionSchema.parse(input); const data = commissionOptionSchema.parse(input);
const created = await prisma.commissionOption.create({ const created = await prisma.commissionOption.create({
@ -37,7 +33,6 @@ export async function updateCommissionOption(id: string, input: unknown) {
} }
export async function deleteCommissionOption(id: string) { export async function deleteCommissionOption(id: string) {
console.log("TBD"); await prisma.commissionOption.delete({ where: { id } });
// await prisma.commissionOption.delete({ where: { id } }); revalidatePath(LIST_PATH);
// revalidatePath(LIST_PATH);
} }

View File

@ -1,7 +1,8 @@
"use server" "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Updates sort order for commission types.
export async function updateCommissionTypeSortOrder( export async function updateCommissionTypeSortOrder(
ordered: { id: string; sortIndex: number }[] ordered: { id: string; sortIndex: number }[]
) { ) {
@ -10,7 +11,7 @@ export async function updateCommissionTypeSortOrder(
where: { id }, where: { id },
data: { sortIndex }, data: { sortIndex },
}) })
) );
await Promise.all(updates) await Promise.all(updates);
} }

View File

@ -4,9 +4,10 @@ import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType"; import { commissionTypeSchema } from "@/schemas/commissionType";
import type * as z from "zod/v4"; import type * as z from "zod/v4";
// Updates a commission type and resets related nested records.
export async function updateCommissionType( export async function updateCommissionType(
id: string, id: string,
rawData: z.infer<typeof commissionTypeSchema>, rawData: z.infer<typeof commissionTypeSchema>
) { ) {
const data = commissionTypeSchema.parse(rawData); const data = commissionTypeSchema.parse(rawData);

View File

@ -1,12 +1,10 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { CountRow } from "@/types/dashboard";
type CountRow<K extends string> = { // Aggregates dashboard stats for admin overview cards and tables.
[P in K]: string; function toCountMapSafe<K extends string>(rows: Array<CountRow<K>>, key: K) {
} & { _count: { _all: number } };
function toCountMapSafe(rows: any[], key: string) {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
for (const r of rows) out[String(r[key])] = Number(r?._count?._all ?? 0); for (const r of rows) out[String(r[key])] = Number(r?._count?._all ?? 0);
return out; return out;

View File

@ -1,17 +1,19 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema" import { tagSchema } from "@/schemas/artworks/tagSchema";
import type { TagFormInput } from "@/schemas/artworks/tagSchema";
// Creates a tag and related category links/aliases.
export async function createTag(formData: TagFormInput) { export async function createTag(formData: TagFormInput) {
const parsed = tagSchema.safeParse(formData) const parsed = tagSchema.safeParse(formData);
if (!parsed.success) { if (!parsed.success) {
console.error("Validation failed", parsed.error) console.error("Validation failed", parsed.error);
throw new Error("Invalid input") throw new Error("Invalid input");
} }
const data = parsed.data const data = parsed.data;
const parentId = data.parentId ?? null; const parentId = data.parentId ?? null;
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
@ -52,5 +54,5 @@ export async function createTag(formData: TagFormInput) {
return tag; return tag;
}); });
return created return created;
} }

View File

@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
// Deletes a tag if it has no artwork references or child tags.
export async function deleteTag(tagId: string) { export async function deleteTag(tagId: string) {
const tag = await prisma.tag.findUnique({ const tag = await prisma.tag.findUnique({
where: { id: tagId }, where: { id: tagId },

View File

@ -1,7 +1,8 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
// Returns tags ordered by sortIndex.
export async function getTags() { export async function getTags() {
return await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } }) return prisma.tag.findMany({ orderBy: { sortIndex: "asc" } });
} }

View File

@ -1,7 +1,8 @@
"use server" "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Returns true if possibleAncestorId is a descendant of tagId (cycle check).
export async function isDescendant(tagId: string, possibleAncestorId: string): Promise<boolean> { export async function isDescendant(tagId: string, possibleAncestorId: string): Promise<boolean> {
// Walk upwards across any category hierarchy; if we hit tagId, it's a cycle. // Walk upwards across any category hierarchy; if we hit tagId, it's a cycle.
const visited = new Set<string>(); const visited = new Set<string>();

View File

@ -1,18 +1,20 @@
"use server" "use server";
import { prisma } from '@/lib/prisma'; import { prisma } from "@/lib/prisma";
import { TagFormInput, tagSchema } from '@/schemas/artworks/tagSchema'; import { tagSchema } from "@/schemas/artworks/tagSchema";
import { isDescendant } from './isDescendant'; import type { TagFormInput } from "@/schemas/artworks/tagSchema";
import { isDescendant } from "./isDescendant";
// Updates a tag and its category/alias relationships.
export async function updateTag(id: string, rawData: TagFormInput) { export async function updateTag(id: string, rawData: TagFormInput) {
const parsed = tagSchema.safeParse(rawData) const parsed = tagSchema.safeParse(rawData);
if (!parsed.success) { if (!parsed.success) {
console.error("Validation failed", parsed.error) console.error("Validation failed", parsed.error);
throw new Error("Invalid input") throw new Error("Invalid input");
} }
const data = parsed.data const data = parsed.data;
const parentId = data.parentId ?? null; const parentId = data.parentId ?? null;
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
@ -111,5 +113,5 @@ export async function updateTag(id: string, rawData: TagFormInput) {
return tag; return tag;
}); });
return updated return updated;
} }

View File

@ -1,10 +1,11 @@
'use server'; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Returns the most recent Terms of Service markdown.
export async function getLatestTos(): Promise<string | null> { export async function getLatestTos(): Promise<string | null> {
const tos = await prisma.termsOfService.findFirst({ const tos = await prisma.termsOfService.findFirst({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
}); });
return tos?.markdown ?? null; return tos?.markdown ?? null;
} }

View File

@ -1,7 +1,8 @@
'use server'; "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Saves a new Terms of Service version.
export async function saveTosAction(markdown: string) { export async function saveTosAction(markdown: string) {
await prisma.termsOfService.create({ await prisma.termsOfService.create({
data: { data: {

View File

@ -1,9 +1,9 @@
"use server";
import { createImageFromFile } from "./createImageFromFile"; import { createImageFromFile } from "./createImageFromFile";
import type { BulkResult } from "@/types/uploads";
type BulkResult = // Bulk image upload server action used by the admin UI.
| { ok: true; artworkId: string; name: string }
| { ok: false; name: string; error: string };
export async function createImagesBulk(formData: FormData): Promise<BulkResult[]> { export async function createImagesBulk(formData: FormData): Promise<BulkResult[]> {
const entries = formData.getAll("file"); const entries = formData.getAll("file");
const files = entries.filter((x): x is File => x instanceof File); const files = entries.filter((x): x is File => x instanceof File);

View File

@ -1,203 +1,12 @@
"use server" "use server";
import { fileUploadSchema } from "@/schemas/artworks/imageSchema"; import type { fileUploadSchema } from "@/schemas/artworks/imageSchema";
import "dotenv/config"; import "dotenv/config";
import { z } from "zod/v4"; import type { z } from "zod/v4";
import { createImageFromFile } from "./createImageFromFile"; import { createImageFromFile } from "./createImageFromFile";
// Creates a single artwork image using the shared upload pipeline.
export async function createImage(values: z.infer<typeof fileUploadSchema>) { export async function createImage(values: z.infer<typeof fileUploadSchema>) {
const imageFile = values.file[0]; const imageFile = values.file[0];
return createImageFromFile(imageFile, { colorMode: "inline" }); return createImageFromFile(imageFile, { colorMode: "inline" });
} }
/*
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
const imageFile = values.file[0];
if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null;
}
const fileName = imageFile.name;
const fileType = imageFile.type;
const fileSize = imageFile.size;
const lastModified = new Date(imageFile.lastModified);
const fileKey = uuidv4();
const arrayBuffer = await imageFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const realFileType = fileType.split("/")[1];
const originalKey = `original/${fileKey}.${realFileType}`;
const modifiedKey = `modified/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnail/${fileKey}.webp`;
const sharpData = sharp(buffer);
const metadata = await sharpData.metadata();
//--- Original file
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: originalKey,
Body: buffer,
ContentType: "image/" + metadata.format,
})
);
//--- Modified file
const modifiedBuffer = await sharp(buffer)
.toFormat('webp')
.toBuffer()
const modifiedMetadata = await sharp(modifiedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: modifiedKey,
Body: modifiedBuffer,
ContentType: "image/" + modifiedMetadata.format,
})
);
//--- Resized file
const { width, height } = modifiedMetadata;
const targetSize = 400;
let resizeOptions;
if (width && height) {
if (height < width) {
resizeOptions = { height: targetSize };
} else {
resizeOptions = { width: targetSize };
}
} else {
resizeOptions = { height: targetSize };
}
const resizedBuffer = await sharp(modifiedBuffer)
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: resizedKey,
Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format,
})
);
//--- Thumbnail file
const thumbnailTargetSize = 160;
let thumbnailOptions;
if (width && height) {
if (height < width) {
thumbnailOptions = { height: thumbnailTargetSize };
} else {
thumbnailOptions = { width: thumbnailTargetSize };
}
} else {
thumbnailOptions = { height: thumbnailTargetSize };
}
const thumbnailBuffer = await sharp(modifiedBuffer)
.resize({ ...thumbnailOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: thumbnailKey,
Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format,
})
);
const fileRecord = await prisma.fileData.create({
data: {
name: fileName,
fileKey,
originalFile: fileName,
uploadDate: lastModified,
fileType: realFileType,
fileSize: fileSize,
},
});
const artworkSlug = fileName.toLowerCase().replace(/\s+/g, "-");
const artworkRecord = await prisma.artwork.create({
data: {
name: fileName,
slug: artworkSlug,
creationDate: lastModified,
fileId: fileRecord.id,
},
});
await prisma.artworkMetadata.create({
data: {
artworkId: artworkRecord.id,
format: metadata.format || "unknown",
width: metadata.width || 0,
height: metadata.height || 0,
space: metadata.space || "unknown",
channels: metadata.channels || 0,
depth: metadata.depth || "unknown",
density: metadata.density ?? undefined,
bitsPerSample: metadata.bitsPerSample ?? undefined,
isProgressive: metadata.isProgressive ?? undefined,
isPalette: metadata.isPalette ?? undefined,
hasProfile: metadata.hasProfile ?? undefined,
hasAlpha: metadata.hasAlpha ?? undefined,
autoOrientW: metadata.autoOrient?.width ?? undefined,
autoOrientH: metadata.autoOrient?.height ?? undefined,
},
});
await prisma.fileVariant.createMany({
data: [
{
s3Key: originalKey,
type: "original",
height: metadata.height,
width: metadata.width,
fileExtension: metadata.format,
mimeType: "image/" + metadata.format,
sizeBytes: metadata.size,
artworkId: artworkRecord.id
},
{
s3Key: modifiedKey,
type: "modified",
height: modifiedMetadata.height,
width: modifiedMetadata.width,
fileExtension: modifiedMetadata.format,
mimeType: "image/" + modifiedMetadata.format,
sizeBytes: modifiedMetadata.size,
artworkId: artworkRecord.id
},
{
s3Key: resizedKey,
type: "resized",
height: resizedMetadata.height,
width: resizedMetadata.width,
fileExtension: resizedMetadata.format,
mimeType: "image/" + resizedMetadata.format,
sizeBytes: resizedMetadata.size,
artworkId: artworkRecord.id
},
{
s3Key: thumbnailKey,
type: "thumbnail",
height: thumbnailMetadata.height,
width: thumbnailMetadata.width,
fileExtension: thumbnailMetadata.format,
mimeType: "image/" + thumbnailMetadata.format,
sizeBytes: thumbnailMetadata.size,
artworkId: artworkRecord.id
}
],
});
return artworkRecord
}
*/

View File

@ -8,12 +8,12 @@ import sharp from "sharp";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors"; import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
// Upload pipeline that generates variants and metadata, then creates artwork records.
export async function createImageFromFile( export async function createImageFromFile(
imageFile: File, imageFile: File,
opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" }, opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" },
) { ) {
if (!(imageFile instanceof File)) { if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null; return null;
} }

View File

@ -2,24 +2,20 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { z } from "zod/v4"; import type { SessionWithRole } from "@/types/auth";
import { createUserSchema } from "@/schemas/users";
import type { CreateUserInput } from "@/schemas/users";
const schema = z.object({ // Creates a new user account (admin-only).
name: z.string().min(1).max(200), export async function createUser(input: CreateUserInput) {
email: z.string().email().max(320),
password: z.string().min(8).max(128),
role: z.enum(["user", "admin"]).default("user"),
});
export async function createUser(input: z.infer<typeof schema>) {
const session = await auth.api.getSession({ headers: await headers() }); 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 || role !== "admin") { if (!session || role !== "admin") {
throw new Error("Forbidden"); throw new Error("Forbidden");
} }
const data = schema.parse(input); const data = createUserSchema.parse(input);
return auth.api.createUser({ return auth.api.createUser({
body: { body: {

View File

@ -4,13 +4,15 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { SessionWithRole } from "@/types/auth";
// Deletes a user account with safety checks (admin-only, cannot delete self or last admin).
export async function deleteUser(id: string) { export async function deleteUser(id: string) {
const userId = z.string().min(1).parse(id); const userId = z.string().min(1).parse(id);
const session = await auth.api.getSession({ headers: await headers() }); 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;
const currentUserId = (session as any)?.user?.id as string | undefined; const currentUserId = (session as SessionWithRole)?.user?.id;
if (!session || role !== "admin") throw new Error("Forbidden"); if (!session || role !== "admin") throw new Error("Forbidden");
if (!currentUserId) throw new Error("Session missing user id"); if (!currentUserId) throw new Error("Session missing user id");
@ -40,5 +42,5 @@ async function await_attachTarget(userId: string) {
select: { id: true, role: true }, select: { id: true, role: true },
}); });
if (!target) throw new Error("User not found."); if (!target) throw new Error("User not found.");
return target as { id: string; role: "admin" | "user" }; return target;
} }

View File

@ -2,21 +2,14 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { SessionWithRole } from "@/types/auth";
import type { UserRole, UsersListRow } from "@/types/users";
import { headers } from "next/headers"; import { headers } from "next/headers";
export type UsersListRow = { // Returns all users for the admin users table.
id: string;
name: string | null;
email: string;
role: "admin" | "user";
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
};
export async function getUsers(): Promise<UsersListRow[]> { export async function getUsers(): Promise<UsersListRow[]> {
const session = await auth.api.getSession({ headers: await headers() }); 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 || role !== "admin") { if (!session || role !== "admin") {
throw new Error("Forbidden"); throw new Error("Forbidden");
@ -35,5 +28,16 @@ export async function getUsers(): Promise<UsersListRow[]> {
}, },
}); });
return rows as UsersListRow[]; return rows.map((r) => {
if (r.role !== "admin" && r.role !== "user") {
throw new Error(`Unexpected user role: ${r.role}`);
}
return {
...r,
role: r.role as UserRole,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
};
});
} }

View File

@ -2,21 +2,20 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { z } from "zod/v4"; import type { SessionWithRole } from "@/types/auth";
import { resendVerificationSchema } from "@/schemas/users";
import type { ResendVerificationInput } from "@/schemas/users";
const schema = z.object({ // Resends a verification email for a user (admin-only).
email: z.string().email(), export async function resendVerification(input: ResendVerificationInput) {
});
export async function resendVerification(input: z.infer<typeof schema>) {
const session = await auth.api.getSession({ headers: await headers() }); 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 || role !== "admin") throw new Error("Forbidden"); if (!session || role !== "admin") throw new Error("Forbidden");
const { email } = schema.parse(input); const { email } = resendVerificationSchema.parse(input);
// Uses the public auth route (same origin) // Uses the public auth route (same origin)
const res = await fetch("http://localhost/api/auth/send-verification-email", { const res = await fetch(`${process.env.BETTER_AUTH_URL}/api/auth/send-verification-email`, {
// NOTE: In production, you should use an absolute URL from env, or use authClient. // NOTE: In production, you should use an absolute URL from env, or use authClient.
// This is kept minimal; if you want, I'll refactor to authClient to avoid hostname concerns. // This is kept minimal; if you want, I'll refactor to authClient to avoid hostname concerns.
method: "POST", method: "POST",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react"; import { PlusCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
// Commission types list page.
export default async function CommissionTypesPage() { export default async function CommissionTypesPage() {
const types = await prisma.commissionType.findMany({ const types = await prisma.commissionType.findMany({
include: { include: {
@ -17,11 +18,18 @@ export default async function CommissionTypesPage() {
<div> <div>
<div className="flex gap-4 justify-between pb-8"> <div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Commission Types</h1> <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 <PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Type
</Link> </Link>
</div> </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> </div>
); );
} }

View File

@ -3,25 +3,15 @@ import Footer from "@/components/global/Footer";
import MobileSidebar from "@/components/global/MobileSidebar"; import MobileSidebar from "@/components/global/MobileSidebar";
import ModeToggle from "@/components/global/ModeToggle"; import ModeToggle from "@/components/global/ModeToggle";
import Sidebar from "@/components/global/Sidebar"; import Sidebar from "@/components/global/Sidebar";
import type { ReactNode } from "react";
// Main admin layout with sidebar, header actions, and footer.
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: ReactNode;
}>) { }>) {
return ( 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="min-h-screen w-full">
<div className="flex 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"> <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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function fmtDate(d: Date) { // Admin dashboard summary page.
return new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
export default async function HomePage() { export default async function HomePage() {
const data = await getAdminDashboard(); const data = await getAdminDashboard();
@ -28,9 +21,6 @@ export default async function HomePage() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* <Button asChild variant="secondary">
<Link href="/artworks/new">Add artwork</Link>
</Button> */}
<Button asChild> <Button asChild>
<Link href="/commissions/requests">Review requests</Link> <Link href="/commissions/requests">Review requests</Link>
</Button> </Button>
@ -80,48 +70,6 @@ export default async function HomePage() {
</section> </section>
<section className="grid gap-4 lg:grid-cols-3"> <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 */} {/* Commissions status */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -153,84 +101,6 @@ export default async function HomePage() {
</CardContent> </CardContent>
</Card> </Card>
</section> </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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm"; import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
// Forgot password page.
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div className="mx-auto max-w-md p-6"> <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({ export default function AuthLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
<div className="flex flex-col min-h-screen min-w-screen"> <div className="flex flex-col min-h-screen min-w-screen">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,24 @@
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { Readable } from "node: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[] }> }) { export async function GET(_req: NextRequest, context: { params: Promise<{ key: string[] }> }) {
const { key } = await context.params; const { key } = await context.params;
const s3Key = key.join("/"); const s3Key = key.join("/");
@ -20,7 +37,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
const contentType = response.ContentType ?? "application/octet-stream"; const contentType = response.ContentType ?? "application/octet-stream";
return new Response(response.Body as ReadableStream, { return new Response(toBodyInit(response.Body as S3Body), {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "public, max-age=3600", "Cache-Control": "public, max-age=3600",
@ -28,7 +45,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
}, },
}); });
} catch (err) { } catch (err) {
console.log(err) console.error(err);
return new Response("Image not found", { status: 404 }); return new Response("Image not found", { status: 404 });
} }
} }

View File

@ -1,9 +1,12 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import type { S3Body } from "@/types/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import archiver from "archiver"; import archiver from "archiver";
import { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { Readable } from "node:stream";
// Streams commission request files (single or zip) from S3.
type Mode = "display" | "download" | "bulk"; type Mode = "display" | "download" | "bulk";
function contentDisposition(filename: string, mode: Mode) { function contentDisposition(filename: string, mode: Mode) {
@ -14,7 +17,38 @@ function contentDisposition(filename: string, mode: Mode) {
} }
function sanitizeZipEntryName(name: string) { function sanitizeZipEntryName(name: string) {
return name.replace(/[^\w.\- ()\[\]]+/g, "_").slice(0, 180); 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 webStreamToAsyncIterable(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
return {
async *[Symbol.asyncIterator]() {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) yield value;
}
} finally {
reader.releaseLock();
}
},
};
}
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) { export async function GET(req: NextRequest) {
@ -52,7 +86,7 @@ export async function GET(req: NextRequest) {
const contentType = file.fileType || s3Res.ContentType || "application/octet-stream"; 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: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
// You can tune caching; admin-only content usually should be private. // You can tune caching; admin-only content usually should be private.
@ -117,8 +151,24 @@ export async function GET(req: NextRequest) {
f.originalFile || f.fileKey.split("/").pop() || "file" f.originalFile || f.fileKey.split("/").pop() || "file"
); );
// obj.Body is a Node stream in Node runtime; works with archiver // obj.Body can be a Node Readable, web ReadableStream, or Buffer.
archive.append(obj.Body as any, { name: entryName }); const body = obj.Body;
if (!body) continue;
if (body instanceof Readable) {
archive.append(body, { name: entryName });
} else if (isWebReadableStream(body)) {
archive.append(Readable.from(webStreamToAsyncIterable(body)), { name: entryName });
} else if (body instanceof Blob) {
const stream = body.stream();
archive.append(Readable.from(webStreamToAsyncIterable(stream)), { name: entryName });
} else if (Buffer.isBuffer(body)) {
archive.append(body, { name: entryName });
} else if (body instanceof Uint8Array) {
archive.append(Buffer.from(body), { name: entryName });
} else {
throw new Error("Unsupported S3 body type for zip entry");
}
} }
await archive.finalize(); await archive.finalize();

View File

@ -1,38 +1,11 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import { publicCommissionRequestSchema } from "@/schemas/commissions/publicRequest";
import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { z } from "zod/v4";
const payloadSchema = z.object({ // Public API endpoint for commission submissions (multipart form).
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",
});
}
});
function safeJsonParse(input: string) { function safeJsonParse(input: string) {
try { try {
@ -64,7 +37,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 }); return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
} }
const payload = payloadSchema.safeParse(parsedJson); const payload = publicCommissionRequestSchema.safeParse(parsedJson);
if (!payload.success) { if (!payload.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation error", issues: payload.error.issues }, { error: "Validation error", issues: payload.error.issues },

View File

@ -2,7 +2,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
export default function Error({ // Global error UI for the app router segment.
export default function ErrorPage({
error, error,
reset, reset,
}: { }: {
@ -26,6 +27,7 @@ export default function Error({
<div className="flex justify-center gap-4 pt-2"> <div className="flex justify-center gap-4 pt-2">
<button <button
type="button"
onClick={reset} onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition" className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition"
> >

View File

@ -1,5 +1,6 @@
"use client"; "use client";
// Root-level error boundary UI.
export default function GlobalError({ export default function GlobalError({
error, error,
reset, reset,
@ -20,6 +21,7 @@ export default function GlobalError({
</p> </p>
<button <button
type="button"
onClick={reset} onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition" className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition"
> >

View File

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

View File

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

View File

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

View File

@ -1,24 +1,26 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react";
import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats"; import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats";
import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors"; import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import * as React from "react";
// Admin tool for processing pending artwork color extraction.
export function ArtworkColorProcessor() { export function ArtworkColorProcessor() {
const [stats, setStats] = React.useState<Awaited<ReturnType<typeof getArtworkColorStats>> | null>(null); const [stats, setStats] = useState<Awaited<ReturnType<typeof getArtworkColorStats>> | null>(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = React.useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const refreshStats = async () => { const refreshStats = useCallback(async () => {
const s = await getArtworkColorStats(); const s = await getArtworkColorStats();
setStats(s); setStats(s);
};
React.useEffect(() => {
void refreshStats();
}, []); }, []);
useEffect(() => {
void refreshStats();
}, [refreshStats]);
const run = async () => { const run = async () => {
setLoading(true); setLoading(true);
setMsg(null); setMsg(null);
@ -47,7 +49,7 @@ export function ArtworkColorProcessor() {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button onClick={run} disabled={loading || done}> <Button type="button" onClick={run} disabled={loading || done}>
{done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"} {done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"}
</Button> </Button>

View File

@ -1,14 +1,17 @@
"use client" "use client";
// import { PortfolioImage } from "@/generated/prisma"; // import { PortfolioImage } from "@/generated/prisma";
// Possibly unused: no references found in `src/app` or `src/components`.
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArtworkWithRelations } from "@/types/Artwork"; import type { ArtworkWithRelations } from "@/types/Artwork";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { CSSProperties } from "react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
// Client-side artwork gallery with incremental pagination.
export default function ArtworkGallery({ export default function ArtworkGallery({
initialArtworks, initialArtworks,
initialCursor, initialCursor,
@ -52,9 +55,7 @@ export default function ArtworkGallery({
return ( return (
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<div <div className="flex flex-wrap gap-4">
className="flex flex-wrap gap-4"
>
{artworks.map((artwork) => ( {artworks.map((artwork) => (
<div key={artwork.id} style={{ width: 200, height: 200 }}> <div key={artwork.id} style={{ width: 200, height: 200 }}>
<Link href={`/artworks/${artwork.id}`}> <Link href={`/artworks/${artwork.id}`}>
@ -66,7 +67,7 @@ export default function ArtworkGallery({
)} )}
style={{ style={{
'--tw-border-opacity': 1, '--tw-border-opacity': 1,
} as React.CSSProperties} } as CSSProperties}
> >
<div <div
className={cn( className={cn(

View File

@ -1,23 +1,25 @@
"use client"; "use client";
// Possibly unused: no references found in `src/app` or `src/components`.
import { generateGalleryVariantsMissing } from "@/actions/artworks/generateGalleryVariant"; import { generateGalleryVariantsMissing } from "@/actions/artworks/generateGalleryVariant";
import { getGalleryVariantStats } from "@/actions/artworks/getGalleryVariantStats"; import { getGalleryVariantStats } from "@/actions/artworks/getGalleryVariantStats";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import * as React from "react"; import { useCallback, useEffect, useState } from "react";
// Admin tool for generating missing gallery variants.
export function ArtworkGalleryVariantProcessor() { export function ArtworkGalleryVariantProcessor() {
const [stats, setStats] = React.useState<Awaited< const [stats, setStats] = useState<Awaited<
ReturnType<typeof getGalleryVariantStats> ReturnType<typeof getGalleryVariantStats>
> | null>(null); > | null>(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = React.useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const refreshStats = React.useCallback(async () => { const refreshStats = useCallback(async () => {
const s = await getGalleryVariantStats(); const s = await getGalleryVariantStats();
setStats(s); setStats(s);
}, []); }, []);
React.useEffect(() => { useEffect(() => {
void refreshStats(); void refreshStats();
}, [refreshStats]); }, [refreshStats]);

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
type Column,
type ColumnDef, type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@ -17,14 +18,11 @@ import {
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import * as React from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { deleteArtwork } from "@/actions/artworks/deleteArtwork"; import { deleteArtwork } from "@/actions/artworks/deleteArtwork";
import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions"; import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions";
import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage"; import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage";
// import type { ArtworkTableRow } from "@/lib/artworks/artworkTableSchema";
// import { MultiSelectFilter } from "@/components/admin/MultiSelectFilter";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -68,11 +66,12 @@ import {
import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema"; import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
import { MultiSelectFilter } from "./MultiSelectFilter"; import { MultiSelectFilter } from "./MultiSelectFilter";
// Client-side table for filtering, sorting, and managing artworks.
type TriState = "any" | "true" | "false"; type TriState = "any" | "true" | "false";
function useDebouncedValue<T>(value: T, delayMs: number) { function useDebouncedValue<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = React.useState(value); const [debounced, setDebounced] = useState(value);
React.useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs); const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [value, delayMs]); }, [value, delayMs]);
@ -81,7 +80,7 @@ function useDebouncedValue<T>(value: T, delayMs: number) {
function SortHeader(props: { function SortHeader(props: {
title: string; title: string;
column: any; // TanStack Column<TData, TValue> column: Column<ArtworkTableRow, unknown>;
}) { }) {
const sorted = props.column.getIsSorted() as false | "asc" | "desc"; const sorted = props.column.getIsSorted() as false | "asc" | "desc";
@ -166,61 +165,53 @@ type Filters = {
name: string; name: string;
slug: string; slug: string;
published: TriState; published: TriState;
nsfw: TriState;
needsWork: TriState; needsWork: TriState;
albumIds: string[];
categoryIds: string[]; categoryIds: string[];
}; };
export function ArtworksTable() { export function ArtworksTable() {
const [sorting, setSorting] = React.useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true }, { id: "updatedAt", desc: true },
]); ]);
const [pageIndex, setPageIndex] = React.useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = React.useState(25); const [pageSize, setPageSize] = useState(25);
const [filters, setFilters] = React.useState<Filters>({ const [filters, setFilters] = useState<Filters>({
name: "", name: "",
slug: "", slug: "",
published: "any", published: "any",
nsfw: "any",
needsWork: "any", needsWork: "any",
albumIds: [],
categoryIds: [], categoryIds: [],
}); });
const debouncedName = useDebouncedValue(filters.name, 300); const debouncedName = useDebouncedValue(filters.name, 300);
const debouncedSlug = useDebouncedValue(filters.slug, 300); const debouncedSlug = useDebouncedValue(filters.slug, 300);
const [rows, setRows] = React.useState<ArtworkTableRow[]>([]); const [rows, setRows] = useState<ArtworkTableRow[]>([]);
const [total, setTotal] = React.useState(0); const [total, setTotal] = useState(0);
const [albumOptions, setAlbumOptions] = React.useState< const [categoryOptions, setCategoryOptions] = useState<
{ id: string; name: string }[]
>([]);
const [categoryOptions, setCategoryOptions] = React.useState<
{ id: string; name: string }[] { id: string; name: string }[]
>([]); >([]);
const [isPending, startTransition] = React.useTransition(); const [isPending, startTransition] = useTransition();
const [deleteOpen, setDeleteOpen] = React.useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ const [deleteTarget, setDeleteTarget] = useState<{
id: string; id: string;
name: string; name: string;
} | null>(null); } | null>(null);
const pageCount = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.max(1, Math.ceil(total / pageSize));
React.useEffect(() => { useEffect(() => {
startTransition(async () => { startTransition(async () => {
const res = await getArtworkFilterOptions(); const res = await getArtworkFilterOptions();
setAlbumOptions(res.albums);
setCategoryOptions(res.categories); setCategoryOptions(res.categories);
}); });
}, []); }, []);
const columns = React.useMemo<ColumnDef<ArtworkTableRow>[]>( const columns = useMemo<ColumnDef<ArtworkTableRow>[]>(
() => [ () => [
{ {
id: "preview", id: "preview",
@ -242,9 +233,9 @@ export function ArtworksTable() {
/> />
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-[520px]"> <HoverCardContent className="w-130">
<div className="flex gap-3"> <div className="flex gap-3">
<div className="relative aspect-[4/3] w-[320px] overflow-hidden rounded-lg border bg-muted"> <div className="relative aspect-4/3 w-[320px] overflow-hidden rounded-lg border bg-muted">
<Image <Image
src={url} src={url}
alt={row.original.name} alt={row.original.name}
@ -295,7 +286,7 @@ export function ArtworksTable() {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => <SortHeader title="Name" column={column} />, header: ({ column }) => <SortHeader title="Name" column={column} />,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0 max-w-[340px]"> <div className="min-w-0 max-w-85">
<Link <Link
href={`/artworks/${row.original.id}`} href={`/artworks/${row.original.id}`}
className="block truncate text-sm font-medium leading-5 underline-offset-4 hover:underline" className="block truncate text-sm font-medium leading-5 underline-offset-4 hover:underline"
@ -309,33 +300,6 @@ export function ArtworksTable() {
</div> </div>
), ),
}, },
{
id: "gallery",
header: "Gallery",
cell: ({ row }) => (
<span className="text-sm text-foreground/80">
{row.original.gallery?.name ?? "—"}
</span>
),
enableSorting: false,
},
{
id: "albums",
header: ({ column }) => <SortHeader title="Albums #" column={column} />,
accessorKey: "albumsCount",
cell: ({ row }) => (
<div className="space-y-1">
<div className="text-sm font-medium tabular-nums">
{row.original.albumsCount}
</div>
{row.original.albums.length ? (
<Chips items={row.original.albums} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
),
},
{ {
id: "categories", id: "categories",
header: ({ column }) => ( header: ({ column }) => (
@ -445,7 +409,7 @@ export function ArtworksTable() {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
React.useEffect(() => { useEffect(() => {
startTransition(async () => { startTransition(async () => {
const res = await getArtworksTablePage({ const res = await getArtworksTablePage({
pagination: { pageIndex, pageSize }, pagination: { pageIndex, pageSize },
@ -455,7 +419,6 @@ export function ArtworksTable() {
slug: debouncedSlug || undefined, slug: debouncedSlug || undefined,
published: filters.published, published: filters.published,
needsWork: filters.needsWork, needsWork: filters.needsWork,
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
categoryIds: filters.categoryIds.length categoryIds: filters.categoryIds.length
? filters.categoryIds ? filters.categoryIds
: undefined, : undefined,
@ -473,7 +436,6 @@ export function ArtworksTable() {
debouncedSlug, debouncedSlug,
filters.published, filters.published,
filters.needsWork, filters.needsWork,
filters.albumIds,
filters.categoryIds, filters.categoryIds,
]); ]);
@ -483,7 +445,7 @@ export function ArtworksTable() {
<div className="space-y-4"> <div className="space-y-4">
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40"> <div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
<div className="relative"> <div className="relative">
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-background/60 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background/60 to-transparent" />
<Table> <Table>
<TableHeader className="sticky top-0 z-20 bg-card"> <TableHeader className="sticky top-0 z-20 bg-card">
{/* main header */} {/* main header */}
@ -549,16 +511,6 @@ export function ArtworksTable() {
setPageIndex(0); setPageIndex(0);
}} }}
/> />
) : colId === "albums" ? (
<MultiSelectFilter
placeholder="Albums…"
options={albumOptions}
value={filters.albumIds}
onChange={(next) => {
setFilters((f) => ({ ...f, albumIds: next }));
setPageIndex(0);
}}
/>
) : colId === "categories" ? ( ) : colId === "categories" ? (
<MultiSelectFilter <MultiSelectFilter
placeholder="Categories…" placeholder="Categories…"
@ -636,7 +588,7 @@ export function ArtworksTable() {
setPageIndex(0); setPageIndex(0);
}} }}
> >
<SelectTrigger className="h-9 w-[120px]"> <SelectTrigger className="h-9 w-30">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -665,7 +617,7 @@ export function ArtworksTable() {
Prev Prev
</Button> </Button>
<div className="min-w-[120px] text-center text-sm tabular-nums"> <div className="min-w-30 text-center text-sm tabular-nums">
Page {pageIndex + 1} / {pageCount} Page {pageIndex + 1} / {pageCount}
</div> </div>
@ -722,9 +674,6 @@ export function ArtworksTable() {
slug: debouncedSlug || undefined, slug: debouncedSlug || undefined,
published: filters.published, published: filters.published,
needsWork: filters.needsWork, needsWork: filters.needsWork,
albumIds: filters.albumIds.length
? filters.albumIds
: undefined,
categoryIds: filters.categoryIds.length categoryIds: filters.categoryIds.length
? filters.categoryIds ? filters.categoryIds
: undefined, : undefined,

View File

@ -1,5 +1,6 @@
"use client"; "use client";
// Possibly unused: no references found in `src/app` or `src/components`.
// import { PortfolioAlbum, PortfolioType } from "@/generated/prisma"; // import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
// import BackfillButton from "./BackfillButton"; // import BackfillButton from "./BackfillButton";
@ -14,6 +15,7 @@ type FilterBarProps = {
// groupId: string; // groupId: string;
}; };
// Filter bar for the artwork gallery (published status only).
export default function FilterBar({ export default function FilterBar({
// types, // types,
// albums, // albums,
@ -44,13 +46,6 @@ export default function FilterBar({
router.push(`${pathname}?${params.toString()}`); router.push(`${pathname}?${params.toString()}`);
}; };
const sortHref = `${pathname}/sort?${params.toString()}`;
const sortByColor = () => {
params.set("sortBy", "color");
router.push(`${pathname}?${params.toString()}`);
};
return ( return (
<div> <div>
<div className="flex gap-6 pb-6"> <div className="flex gap-6 pb-6">
@ -148,7 +143,6 @@ export default function FilterBar({
</div> */} </div> */}
</div> </div>
</div> </div>
); );
} }
@ -163,6 +157,7 @@ function FilterButton({
}) { }) {
return ( return (
<button <button
type="button"
onClick={onClick} onClick={onClick}
className={`px-3 py-1 rounded text-sm border transition ${active className={`px-3 py-1 rounded text-sm border transition ${active
? "bg-primary text-white border-primary" ? "bg-primary text-white border-primary"

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react"; import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
@ -9,13 +9,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
type Option = { id: string; name: string }; type Option = { id: string; name: string };
// Simple multi-select filter control for artwork filters.
export function MultiSelectFilter(props: { export function MultiSelectFilter(props: {
placeholder: string; placeholder: string;
options: Option[]; options: Option[];
value: string[]; // selected ids value: string[]; // selected ids
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
}) { }) {
const selected = React.useMemo(() => new Set(props.value), [props.value]); const selected = useMemo(() => new Set(props.value), [props.value]);
return ( return (
<Popover> <Popover>
@ -30,7 +31,7 @@ export function MultiSelectFilter(props: {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start"> <PopoverContent className="w-70 p-0" align="start">
<Command> <Command>
<CommandInput placeholder="Search…" /> <CommandInput placeholder="Search…" />
<CommandEmpty>No results.</CommandEmpty> <CommandEmpty>No results.</CommandEmpty>

Some files were not shown because too many files have changed in this diff Show More