Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d75501860d
|
|||
|
ff886d3002
|
|||
|
48c7d522c1
|
|||
|
531bb8750e
|
|||
|
8572e22c5d
|
@ -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 },
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
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 },
|
||||||
include: { color: true },
|
include: { color: true },
|
||||||
orderBy: [{ type: "asc" }],
|
orderBy: [{ type: "asc" }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" } }),
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
"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
|
||||||
) {
|
) {
|
||||||
const validated = artworkSchema.safeParse(values);
|
const validated = artworkSchema.safeParse(values);
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -32,6 +33,6 @@ export async function deleteCategory(catId: string) {
|
|||||||
await prisma.artCategory.delete({ where: { id: catId } });
|
await prisma.artCategory.delete({ where: { id: catId } });
|
||||||
|
|
||||||
revalidatePath("/categories");
|
revalidatePath("/categories");
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" }],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 }[]
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
})
|
});
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
@ -39,6 +40,6 @@ export async function deleteTag(tagId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/tags");
|
revalidatePath("/tags");
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
'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: {
|
||||||
markdown,
|
markdown,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
@ -15,4 +16,4 @@ export default async function PortfolioCategoriesEditPage({ params }: { params:
|
|||||||
{category && <EditCategoryForm category={category} />}
|
{category && <EditCategoryForm category={category} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -7,4 +8,4 @@ export default function PortfolioCategoriesNewPage() {
|
|||||||
<NewCategoryForm />
|
<NewCategoryForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
@ -24,4 +28,4 @@ export default async function CategoriesPage() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,11 +16,11 @@ 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} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
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" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return <ExtraListClient extras={extras} />;
|
return <ExtraListClient extras={extras} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
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" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return <OptionsListClient options={options} />;
|
return <OptionsListClient options={options} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
@ -14,4 +15,4 @@ export default async function TosPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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("/");
|
||||||
|
|||||||
@ -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("/");
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
@ -20,4 +21,4 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
export const { POST, GET } = toNextJsHandler(auth);
|
// Better Auth route handlers.
|
||||||
|
export const { POST, GET } = toNextJsHandler(auth);
|
||||||
|
|||||||
@ -1,10 +1,27 @@
|
|||||||
import { s3 } from "@/lib/s3";
|
import { 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("/");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
@ -20,7 +37,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
|
|||||||
|
|
||||||
const contentType = response.ContentType ?? "application/octet-stream";
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
@ -96,4 +97,4 @@ export default function ArtworkGallery({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
Reference in New Issue
Block a user