Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
940e934237
|
|||
|
aa95635e3e
|
|||
|
1940867519
|
|||
|
3e6d045cbd
|
|||
|
c712f31759
|
|||
|
eb8dcd54a8
|
|||
|
030065631c
|
|||
|
5a3e567ed5
|
|||
|
84dc219a14
|
|||
|
96efd4c942
|
@ -271,6 +271,26 @@ model CommissionType {
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionCustomCard {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String
|
||||
|
||||
description String?
|
||||
referenceImageUrl String?
|
||||
isVisible Boolean @default(true)
|
||||
isSpecialOffer Boolean @default(false)
|
||||
|
||||
options CommissionCustomCardOption[]
|
||||
extras CommissionCustomCardExtra[]
|
||||
requests CommissionRequest[]
|
||||
|
||||
@@index([isVisible, sortIndex])
|
||||
}
|
||||
|
||||
model CommissionOption {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@ -282,6 +302,7 @@ model CommissionOption {
|
||||
description String?
|
||||
|
||||
types CommissionTypeOption[]
|
||||
customCards CommissionCustomCardOption[]
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
@ -316,6 +337,7 @@ model CommissionExtra {
|
||||
|
||||
requests CommissionRequest[]
|
||||
types CommissionTypeExtra[]
|
||||
customCards CommissionCustomCardExtra[]
|
||||
}
|
||||
|
||||
model CommissionTypeExtra {
|
||||
@ -337,6 +359,25 @@ model CommissionTypeExtra {
|
||||
@@unique([typeId, extraId])
|
||||
}
|
||||
|
||||
model CommissionCustomCardOption {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
cardId String
|
||||
optionId String
|
||||
|
||||
priceRange String?
|
||||
pricePercent Float?
|
||||
price Float?
|
||||
|
||||
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
option CommissionOption @relation(fields: [optionId], references: [id])
|
||||
|
||||
@@unique([cardId, optionId])
|
||||
}
|
||||
|
||||
model CommissionCustomInput {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
@ -368,6 +409,25 @@ model CommissionTypeCustomInput {
|
||||
@@unique([typeId, customInputId])
|
||||
}
|
||||
|
||||
model CommissionCustomCardExtra {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
cardId String
|
||||
extraId String
|
||||
|
||||
priceRange String?
|
||||
pricePercent Float?
|
||||
price Float?
|
||||
|
||||
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
extra CommissionExtra @relation(fields: [extraId], references: [id])
|
||||
|
||||
@@unique([cardId, extraId])
|
||||
}
|
||||
|
||||
model CommissionRequest {
|
||||
id String @id @default(cuid())
|
||||
index Int @default(autoincrement())
|
||||
@ -386,8 +446,10 @@ model CommissionRequest {
|
||||
|
||||
optionId String?
|
||||
typeId String?
|
||||
customCardId String?
|
||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
|
||||
|
||||
extras CommissionExtra[]
|
||||
files CommissionRequestFile[]
|
||||
@ -399,6 +461,7 @@ model CommissionGuidelines {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
markdown String
|
||||
exampleImageUrl String?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([isActive])
|
||||
|
||||
94
src/actions/animalStudies/getAnimalStudiesPage.ts
Normal file
94
src/actions/animalStudies/getAnimalStudiesPage.ts
Normal file
@ -0,0 +1,94 @@
|
||||
"use server";
|
||||
|
||||
import type { JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
export type AnimalStudiesCursor = { sortKey: number; id: string } | null;
|
||||
|
||||
export type AnimalStudiesPage = {
|
||||
items: JustifiedGalleryItem[];
|
||||
nextCursor: AnimalStudiesCursor;
|
||||
};
|
||||
|
||||
const inputSchema = z.object({
|
||||
take: z.number().int().min(1).max(200).default(60),
|
||||
cursor: z
|
||||
.object({
|
||||
sortKey: z.number().int(),
|
||||
id: z.string().min(1),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
tagSlugs: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function getAnimalStudiesPage(input: unknown): Promise<AnimalStudiesPage> {
|
||||
const { take, cursor, tagSlugs } = inputSchema.parse(input);
|
||||
|
||||
const where: Prisma.ArtworkWhereInput = {
|
||||
published: true,
|
||||
// enforce deterministic ordering / pagination
|
||||
sortKey: { not: null },
|
||||
categories: { some: { name: "Animal Studies" } },
|
||||
};
|
||||
|
||||
if (tagSlugs?.length) {
|
||||
where.tags = { some: { slug: { in: tagSlugs } } };
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
where.OR = [
|
||||
{ sortKey: { gt: cursor.sortKey } },
|
||||
{ sortKey: cursor.sortKey, id: { gt: cursor.id } },
|
||||
];
|
||||
}
|
||||
|
||||
const rows = await prisma.artwork.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
altText: true,
|
||||
sortKey: true,
|
||||
file: { select: { fileKey: true } },
|
||||
variants: {
|
||||
where: { type: "resized" },
|
||||
select: { width: true, height: true },
|
||||
take: 1,
|
||||
},
|
||||
metadata: { select: { width: true, height: true } },
|
||||
colors: {
|
||||
select: { color: { select: { hex: true } } },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||
take: take + 1,
|
||||
});
|
||||
|
||||
const slice = rows.slice(0, take);
|
||||
const next = rows.length > take ? rows[take] : null;
|
||||
|
||||
const items: JustifiedGalleryItem[] = slice.map((r) => {
|
||||
const v = r.variants[0];
|
||||
const w = v?.width ?? r.metadata?.width ?? 4;
|
||||
const h = v?.height ?? r.metadata?.height ?? 3;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
altText: r.altText,
|
||||
fileKey: r.file.fileKey,
|
||||
width: w,
|
||||
height: h,
|
||||
dominantHex: r.colors?.[0]?.color?.hex ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const nextCursor: AnimalStudiesCursor =
|
||||
next && next.sortKey != null ? { sortKey: next.sortKey, id: next.id } : null;
|
||||
|
||||
return { items, nextCursor };
|
||||
}
|
||||
@ -2,17 +2,9 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Server action
|
||||
* Forwards a multipart/form-data request (payload + files[])
|
||||
* from the public app to the admin app's public commissions endpoint.
|
||||
*
|
||||
* Server-only env required:
|
||||
* ADMIN_URL=https://admin.domain.com
|
||||
*/
|
||||
|
||||
const submitPayloadSchema = z.object({
|
||||
typeId: z.string().optional().nullable(),
|
||||
customCardId: z.string().optional().nullable(),
|
||||
optionId: z.string().optional().nullable(),
|
||||
extraIds: z.array(z.string()).default([]),
|
||||
|
||||
@ -20,6 +12,23 @@ const submitPayloadSchema = z.object({
|
||||
customerEmail: z.string().email(),
|
||||
customerSocials: z.string().optional().nullable(),
|
||||
message: z.string().min(1),
|
||||
}).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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type SubmitCommissionPayload = z.infer<typeof submitPayloadSchema>;
|
||||
@ -36,7 +45,6 @@ export async function submitCommissionRequest(input: {
|
||||
const payload = submitPayloadSchema.parse(input.payload);
|
||||
const files = input.files ?? [];
|
||||
|
||||
// Optional safety limits
|
||||
const MAX_FILES = 10;
|
||||
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
@ -70,7 +78,6 @@ export async function submitCommissionRequest(input: {
|
||||
const raw = await res.text().catch(() => "");
|
||||
const statusLine = `${res.status} ${res.statusText || ""}`.trim();
|
||||
|
||||
// Show something useful even if raw is empty
|
||||
let message = `Admin API error: ${statusLine}`;
|
||||
|
||||
if (raw) {
|
||||
@ -87,12 +94,9 @@ export async function submitCommissionRequest(input: {
|
||||
}
|
||||
}
|
||||
|
||||
// Log full body server-side for debugging (safe; this is server-only)
|
||||
console.error("[submitCommissionRequest] upstream error", { statusLine, raw });
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Expected response: { id: string; createdAt: string }
|
||||
return (await res.json()) as { id: string; createdAt: string };
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import { Prisma } from "@/generated/prisma/browser";
|
||||
import type { Prisma } from "@/generated/prisma/browser";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function coerceYear(y: PortfolioFilters["year"]) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/browser";
|
||||
import type { Prisma } from "@/generated/prisma/browser";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type Cursor = {
|
||||
@ -11,7 +11,6 @@ export type Cursor = {
|
||||
export type PortfolioArtworkItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
altText: string | null;
|
||||
|
||||
sortKey: number | null;
|
||||
@ -63,7 +62,8 @@ export async function getPortfolioArtworksPage(args: {
|
||||
|
||||
const year = coerceYear(filters.year ?? null);
|
||||
const q = normQ(filters.q);
|
||||
const albumId = filters.albumId && filters.albumId !== "all" ? filters.albumId : null;
|
||||
const albumId =
|
||||
filters.albumId && filters.albumId !== "all" ? filters.albumId : null;
|
||||
|
||||
const baseWhere: Prisma.ArtworkWhereInput = {
|
||||
...(onlyPublished ? { published: true } : {}),
|
||||
@ -79,10 +79,9 @@ export async function getPortfolioArtworksPage(args: {
|
||||
{
|
||||
OR: [
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
{ slug: { contains: q, mode: "insensitive" } },
|
||||
{ altText: { contains: q, mode: "insensitive" } },
|
||||
{ tags: { some: { name: { contains: q, mode: "insensitive" } } } },
|
||||
{ albums: { some: { name: { contains: q, mode: "insensitive" } } } },
|
||||
{
|
||||
tags: { some: { name: { contains: q, mode: "insensitive" } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -108,7 +107,6 @@ export async function getPortfolioArtworksPage(args: {
|
||||
.filter((y): y is number => typeof y === "number")
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
// Segment logic (sortKey != null first, then null)
|
||||
const inNullSegment = cursor?.afterSortKey === null;
|
||||
|
||||
const select = {
|
||||
@ -130,14 +128,15 @@ export async function getPortfolioArtworksPage(args: {
|
||||
},
|
||||
} satisfies Prisma.ArtworkSelect;
|
||||
|
||||
const mapRow = (r: any): PortfolioArtworkItem | null => {
|
||||
type ArtworkRow = Prisma.ArtworkGetPayload<{ select: typeof select }>;
|
||||
|
||||
const mapRow = (r: ArtworkRow): PortfolioArtworkItem | null => {
|
||||
const thumb = pickVariant(r.variants, "thumbnail");
|
||||
if (!thumb?.width || !thumb?.height) return null;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
altText: r.altText ?? null,
|
||||
sortKey: r.sortKey ?? null,
|
||||
year: r.year ?? null,
|
||||
@ -171,20 +170,26 @@ export async function getPortfolioArtworksPage(args: {
|
||||
select,
|
||||
});
|
||||
|
||||
items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
items = rowsA
|
||||
.map(mapRow)
|
||||
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
|
||||
if (items.length >= take) {
|
||||
const last = items[items.length - 1]!;
|
||||
nextCursor = { afterSortKey: last.sortKey!, afterId: last.id };
|
||||
const last = items.at(-1);
|
||||
if (!last) {
|
||||
return { items, nextCursor: null, total, years, albums };
|
||||
}
|
||||
if (last.sortKey == null) {
|
||||
return { items, nextCursor: null, total, years, albums };
|
||||
}
|
||||
nextCursor = { afterSortKey: last.sortKey, afterId: last.id };
|
||||
return { items, nextCursor, total, years, albums };
|
||||
}
|
||||
|
||||
const remaining = take - items.length;
|
||||
const lastAId = items.length ? items[items.length - 1]!.id : null;
|
||||
|
||||
const whereB: Prisma.ArtworkWhereInput = {
|
||||
AND: [where, { sortKey: null }],
|
||||
...(lastAId ? { id: { gt: lastAId } } : {}),
|
||||
};
|
||||
|
||||
const rowsB = await prisma.artwork.findMany({
|
||||
@ -194,7 +199,9 @@ export async function getPortfolioArtworksPage(args: {
|
||||
select,
|
||||
});
|
||||
|
||||
const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
const more = rowsB
|
||||
.map(mapRow)
|
||||
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
items = items.concat(more);
|
||||
|
||||
const last = items[items.length - 1];
|
||||
@ -218,11 +225,15 @@ export async function getPortfolioArtworksPage(args: {
|
||||
select,
|
||||
});
|
||||
|
||||
items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
items = rowsB
|
||||
.map(mapRow)
|
||||
.filter((x): x is PortfolioArtworkItem => x !== null);
|
||||
|
||||
const last = items[items.length - 1];
|
||||
nextCursor =
|
||||
items.length < take || !last ? null : { afterSortKey: null, afterId: last.id };
|
||||
items.length < take || !last
|
||||
? null
|
||||
: { afterSortKey: null, afterId: last.id };
|
||||
|
||||
return { items, nextCursor, total, years, albums };
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@ export default async function AnimalListPage() {
|
||||
{list.map((a) => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/artworks/single/${a.id}`}
|
||||
href={`/artworks/single/${a.id}?from=animal-index`}
|
||||
className="
|
||||
inline-flex items-center gap-2
|
||||
rounded-md px-2 py-1
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import ArtworkThumbGallery from "@/components/artworks/ArtworkThumbGallery";
|
||||
import AnimalStudiesGallery from "@/components/animalStudies/AnimalStudiesGallery";
|
||||
import TagFilterDialog from "@/components/artworks/TagFilterDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@ -19,7 +19,7 @@ function expandSelectedWithChildren(
|
||||
tagsForFilter: Array<{
|
||||
slug: string;
|
||||
children: Array<{ slug: string }>;
|
||||
}>
|
||||
}>,
|
||||
) {
|
||||
const bySlug = new Map(tagsForFilter.map((t) => [t.slug, t]));
|
||||
const out = new Set(selectedSlugs);
|
||||
@ -33,7 +33,11 @@ function expandSelectedWithChildren(
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
export default async function AnimalStudiesPage({ searchParams }: { searchParams: { tags?: string | string[] } }) {
|
||||
export default async function AnimalStudiesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { tags?: string | string[] };
|
||||
}) {
|
||||
const { tags } = await searchParams;
|
||||
|
||||
const selectedTagSlugs = parseTagsParam(tags);
|
||||
@ -57,28 +61,6 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
||||
|
||||
const expandedTagSlugs = expandSelectedWithChildren(selectedTagSlugs, tagsForFilter);
|
||||
|
||||
const artworks = await prisma.artwork.findMany({
|
||||
where: {
|
||||
categories: { some: { name: "Animal Studies" } },
|
||||
published: true,
|
||||
...(expandedTagSlugs.length
|
||||
? { tags: { some: { slug: { in: expandedTagSlugs } } } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
file: true,
|
||||
metadata: true,
|
||||
tags: true,
|
||||
variants: true,
|
||||
colors: {
|
||||
select: { color: { select: { hex: true } } }
|
||||
}
|
||||
},
|
||||
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||
});
|
||||
|
||||
// console.log(JSON.stringify(artworks, null, 4))
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
@ -88,16 +70,14 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedTagSlugs.length > 0
|
||||
? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"}`
|
||||
? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"
|
||||
}`
|
||||
: "Browse all published artworks in this category."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<TagFilterDialog
|
||||
tags={tagsForFilter}
|
||||
selectedTagSlugs={selectedTagSlugs}
|
||||
/>
|
||||
<TagFilterDialog tags={tagsForFilter} selectedTagSlugs={selectedTagSlugs} />
|
||||
|
||||
<Button asChild type="button" variant="secondary" className="h-11 gap-2">
|
||||
<Link href="/artworks/animalstudies/index">
|
||||
@ -108,7 +88,7 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ArtworkThumbGallery items={artworks} fit={{ mode: "fixedWidth", width: 300 }} />
|
||||
<AnimalStudiesGallery key={expandedTagSlugs.join(",")} tagSlugs={expandedTagSlugs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery";
|
||||
import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import PortfolioFiltersBar from "@/components/portfolio/PortfolioFiltersBar";
|
||||
import PortfolioGallery from "@/components/portfolio/PortfolioGallery";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type SearchParams = {
|
||||
@ -14,11 +14,11 @@ function parseFilters(sp: SearchParams): PortfolioFilters {
|
||||
const yearRaw = sp.year?.trim();
|
||||
if (yearRaw && yearRaw !== "all") {
|
||||
const y = Number(yearRaw);
|
||||
if (Number.isFinite(y) && y > 0) (filters as any).year = y;
|
||||
if (Number.isFinite(y) && y > 0) filters.year = y;
|
||||
}
|
||||
|
||||
const qRaw = sp.q?.trim();
|
||||
if (qRaw) (filters as any).q = qRaw;
|
||||
if (qRaw) filters.q = qRaw;
|
||||
|
||||
return filters;
|
||||
}
|
||||
@ -53,12 +53,22 @@ export default async function PortfolioPage({
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold">Portfolio</h1>
|
||||
<PortfolioFiltersBar years={years} />
|
||||
</div>
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Portfolio
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse all published artworks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ColorMasonryGallery filters={filters} />
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<PortfolioFiltersBar years={years} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<PortfolioGallery filters={filters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,30 +1,82 @@
|
||||
import { CommissionCard } from "@/components/commissions/CommissionCard";
|
||||
import { CommissionCustomCard } from "@/components/commissions/CommissionCustomCard";
|
||||
import CommissionGuidelines from "@/components/commissions/CommissionGuidelines";
|
||||
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Image from "next/image";
|
||||
|
||||
export default async function CommissionsPage() {
|
||||
const commissions = await prisma.commissionType.findMany({
|
||||
include: {
|
||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
|
||||
},
|
||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||
})
|
||||
const [commissions, customCards, guidelines] = await Promise.all([
|
||||
prisma.commissionType.findMany({
|
||||
include: {
|
||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
|
||||
},
|
||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||
}),
|
||||
prisma.commissionCustomCard.findMany({
|
||||
where: { isVisible: true },
|
||||
include: {
|
||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||
},
|
||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||
}),
|
||||
prisma.commissionGuidelines.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { exampleImageUrl: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8 flex flex-col gap-8">
|
||||
<h1 className="text-3xl font-bold">Commission Pricing</h1>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl font-bold">Commission Pricing</h1>
|
||||
{guidelines?.exampleImageUrl ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">View example</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Commission example</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[85vh] max-w-[85vw] items-center justify-center rounded-xl border-border/60 bg-muted p-2 shadow-2xl">
|
||||
<Image
|
||||
src={guidelines.exampleImageUrl}
|
||||
alt="Commission example"
|
||||
width={1600}
|
||||
height={1200}
|
||||
sizes="85vw"
|
||||
className="h-auto max-h-[85vh] w-auto max-w-[85vw] rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
|
||||
{commissions.map((commission) => (
|
||||
<CommissionCard key={commission.id} commission={commission} />
|
||||
))}
|
||||
{customCards.map((card) => (
|
||||
<CommissionCustomCard key={card.id} card={card} />
|
||||
))}
|
||||
<CommissionGuidelines />
|
||||
</div>
|
||||
<hr />
|
||||
<h2 className="text-2xl font-semibold">Request a Commission</h2>
|
||||
<CommissionOrderForm types={commissions} />
|
||||
<CommissionOrderForm types={commissions} customCards={customCards} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
122
src/app/(normal)/commissions/status/page.tsx
Normal file
122
src/app/(normal)/commissions/status/page.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
ACCEPTED: "bg-sky-500/15 text-sky-300 border-sky-500/30",
|
||||
INPROGRESS: "bg-amber-500/15 text-amber-300 border-amber-500/30",
|
||||
COMPLETED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
ACCEPTED: "Accepted",
|
||||
INPROGRESS: "In progress",
|
||||
COMPLETED: "Completed",
|
||||
};
|
||||
|
||||
export default async function CommissionStatusPage() {
|
||||
const [queueItems, doneItems] = await Promise.all([
|
||||
prisma.commissionRequest.findMany({
|
||||
where: { status: { in: ["ACCEPTED", "INPROGRESS"] } },
|
||||
include: { type: true, option: true, extras: true, customCard: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.commissionRequest.findMany({
|
||||
where: { status: "COMPLETED" },
|
||||
include: { type: true, option: true, extras: true, customCard: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8 flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-bold">Commission Status</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<section className="rounded-2xl border border-border/60 bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold">Commissions Queue</h2>
|
||||
<p className="text-sm text-muted-foreground">Accepted and in progress</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{queueItems.length > 0 ? (
|
||||
queueItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-border/60 bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div className="font-semibold text-lg">{item.customerName}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{item.customCard?.name ?? item.type?.name ?? "Custom"}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{item.option?.name ?? "Base option"}
|
||||
</Badge>
|
||||
{item.extras.map((extra) => (
|
||||
<Badge key={extra.id} variant="secondary">
|
||||
{extra.name}
|
||||
</Badge>
|
||||
))}
|
||||
{item.extras.length === 0 ? (
|
||||
<Badge variant="secondary">No extras</Badge>
|
||||
) : null}
|
||||
<Badge className={statusStyles[item.status] ?? ""}>
|
||||
{statusLabels[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-background p-4 text-sm text-muted-foreground">
|
||||
No public items yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/60 bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold">Done</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{doneItems.length > 0 ? (
|
||||
doneItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl border border-border/60 bg-background p-4 shadow-sm"
|
||||
>
|
||||
<div className="font-semibold text-lg">{item.customerName}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{item.customCard?.name ?? item.type?.name ?? "Custom"}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{item.option?.name ?? "Base option"}
|
||||
</Badge>
|
||||
{item.extras.map((extra) => (
|
||||
<Badge key={extra.id} variant="secondary">
|
||||
{extra.name}
|
||||
</Badge>
|
||||
))}
|
||||
{item.extras.length === 0 ? (
|
||||
<Badge variant="secondary">No extras</Badge>
|
||||
) : null}
|
||||
<Badge className={statusStyles[item.status] ?? ""}>
|
||||
{statusLabels[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-background p-4 text-sm text-muted-foreground">
|
||||
No completed items yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -19,29 +19,6 @@ export default function Home() {
|
||||
<SocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Section Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{/* <p>
|
||||
If you want to commission me you can find all the information you need under following link: <a href="https://linktr.ee/gaertan" target="_blank">Linktree</a>
|
||||
</p> */}
|
||||
{/* {sections.map((section) => (
|
||||
<Link href={section.href} key={section.title}>
|
||||
<Card className="hover:shadow-xl transition-shadow group">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<section.icon className="w-5 h-5 text-muted-foreground group-hover:text-primary" />
|
||||
{section.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{section.description}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))} */}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,8 +6,6 @@ export default async function TosPage() {
|
||||
orderBy: [{ version: "desc" }],
|
||||
})
|
||||
|
||||
// console.log(tos?.markdown)
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<div className="markdown">
|
||||
|
||||
@ -24,7 +24,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Disposition": "inline", // use 'attachment' to force download
|
||||
"Content-Disposition": "inline",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@ -48,119 +48,33 @@
|
||||
--color-hover-foreground: var(--hover-foreground);
|
||||
}
|
||||
|
||||
/* :root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
} */
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* Light: warm paper + graphite */
|
||||
--background: oklch(0.985 0.012 85); /* warm off-white */
|
||||
--foreground: oklch(0.18 0.02 35); /* graphite */
|
||||
|
||||
--card: oklch(0.992 0.008 85); /* slightly lifted paper */
|
||||
--background: oklch(0.985 0.012 85);
|
||||
--foreground: oklch(0.18 0.02 35);
|
||||
--card: oklch(0.992 0.008 85);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: oklch(0.992 0.008 85);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
/* Primary: deep ink / indigo (artist-y but still neutral enough) */
|
||||
--primary: oklch(0.32 0.06 260);
|
||||
--primary-foreground: oklch(0.985 0.012 85);
|
||||
|
||||
/* Secondary/muted/accent: warm washes */
|
||||
--secondary: oklch(0.96 0.015 85);
|
||||
--secondary-foreground: oklch(0.22 0.02 35);
|
||||
|
||||
--muted: oklch(0.955 0.012 85);
|
||||
--muted-foreground: oklch(0.46 0.02 35);
|
||||
|
||||
--accent: oklch(0.95 0.02 110); /* subtle “wash” */
|
||||
--accent: oklch(0.95 0.02 110);
|
||||
--accent-foreground: oklch(0.22 0.02 35);
|
||||
|
||||
--destructive: oklch(0.58 0.22 27.325);
|
||||
|
||||
--border: oklch(0.90 0.02 85); /* warm border */
|
||||
--border: oklch(0.90 0.02 85);
|
||||
--input: oklch(0.90 0.02 85);
|
||||
--ring: oklch(0.55 0.07 260); /* ties to primary */
|
||||
|
||||
--hover: oklch(0.94 0.015 255); /* subtle cool lift */
|
||||
--ring: oklch(0.55 0.07 260);
|
||||
--hover: oklch(0.94 0.015 255);
|
||||
--hover-foreground: var(--foreground);
|
||||
|
||||
/* charts can stay, or we can harmonize later */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
/* sidebar inherits the same “paper” idea */
|
||||
--sidebar: oklch(0.975 0.012 85);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--primary);
|
||||
@ -172,36 +86,38 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Inky navy background (clearly not neutral) */
|
||||
--background: oklch(0.12 0.035 255);
|
||||
--foreground: oklch(0.95 0.012 85);
|
||||
|
||||
/* Surfaces */
|
||||
--card: oklch(0.155 0.03 255);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: oklch(0.155 0.03 255);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
/* Primary accent stays “artist ink” */
|
||||
--primary: oklch(0.78 0.11 255);
|
||||
--primary-foreground: oklch(0.13 0.03 255);
|
||||
|
||||
--secondary: oklch(0.19 0.025 255);
|
||||
--secondary-foreground: var(--foreground);
|
||||
|
||||
--muted: oklch(0.19 0.025 255);
|
||||
--muted-foreground: oklch(0.74 0.02 85);
|
||||
|
||||
--accent: oklch(0.22 0.03 110);
|
||||
--accent-foreground: var(--foreground);
|
||||
|
||||
--border: oklch(0.40 0.03 255 / 55%);
|
||||
--input: oklch(0.40 0.03 255 / 65%);
|
||||
--ring: oklch(0.65 0.10 255);
|
||||
|
||||
--hover: oklch(28.783% 0.03139 250.817);
|
||||
--hover-foreground: var(--foreground);
|
||||
--background: oklch(0.2223 0.0060 271.1393);
|
||||
--foreground: oklch(0.9551 0 0);
|
||||
--card: oklch(0.2568 0.0076 274.6528);
|
||||
--card-foreground: oklch(0.9551 0 0);
|
||||
--popover: oklch(0.2568 0.0076 274.6528);
|
||||
--popover-foreground: oklch(0.9551 0 0);
|
||||
--primary: oklch(0.6132 0.2294 291.7437);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.2940 0.0130 272.9312);
|
||||
--secondary-foreground: oklch(0.9551 0 0);
|
||||
--muted: oklch(0.2940 0.0130 272.9312);
|
||||
--muted-foreground: oklch(0.7058 0 0);
|
||||
--accent: oklch(0.2795 0.0368 260.0310);
|
||||
--accent-foreground: oklch(0.7857 0.1153 246.6596);
|
||||
--destructive: oklch(0.7106 0.1661 22.2162);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--border: oklch(0.3289 0.0092 268.3843);
|
||||
--input: oklch(0.3289 0.0092 268.3843);
|
||||
--ring: oklch(0.6132 0.2294 291.7437);
|
||||
--chart-1: oklch(0.8003 0.1821 151.7110);
|
||||
--chart-2: oklch(0.6132 0.2294 291.7437);
|
||||
--chart-3: oklch(0.8077 0.1035 19.5706);
|
||||
--chart-4: oklch(0.6691 0.1569 260.1063);
|
||||
--chart-5: oklch(0.7058 0 0);
|
||||
--sidebar: oklch(0.2011 0.0039 286.0396);
|
||||
--sidebar-foreground: oklch(0.9551 0 0);
|
||||
--sidebar-primary: oklch(0.6132 0.2294 291.7437);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.2940 0.0130 272.9312);
|
||||
--sidebar-accent-foreground: oklch(0.6132 0.2294 291.7437);
|
||||
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||
--sidebar-ring: oklch(0.6132 0.2294 291.7437);
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@ -260,10 +176,6 @@
|
||||
@apply line-through text-muted-foreground;
|
||||
}
|
||||
|
||||
/* div:hover {
|
||||
border-color: var(--hover-border-color);
|
||||
} */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@ -276,7 +188,7 @@
|
||||
}
|
||||
.dark body {
|
||||
background-image:
|
||||
radial-gradient(1200px 700px at 15% -10%, oklch(0.55 0.14 255 / 16%), transparent 60%),
|
||||
radial-gradient(900px 600px at 85% 0%, oklch(0.50 0.12 110 / 10%), transparent 55%);
|
||||
radial-gradient(1200px 700px at 15% -10%, oklch(0.35 0.06 35 / 10%), transparent 60%),
|
||||
radial-gradient(900px 600px at 85% 0%, oklch(0.30 0.05 255 / 6%), transparent 55%);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,11 @@ const geistMono = Geist_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: "Gaertan Art",
|
||||
description: "Portfolio, Artworks and Commission Requests",
|
||||
alternates: {
|
||||
types: {
|
||||
"application/rss+xml": "/rss.xml",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
71
src/app/rss.xml/route.ts
Normal file
71
src/app/rss.xml/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const BASE_URL = `${process.env.FEED_URL}`
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.artwork.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
altText: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const lastBuildDate =
|
||||
items[0]?.updatedAt?.toUTCString() ?? new Date().toUTCString();
|
||||
|
||||
const itemXml = items
|
||||
.map((item) => {
|
||||
const title = escapeXml(item.name || "Artwork");
|
||||
const description = escapeXml(
|
||||
item.description || item.altText || item.name || "Artwork"
|
||||
);
|
||||
const link = `${BASE_URL}/artworks/single/${item.id}`;
|
||||
const pubDate = item.createdAt.toUTCString();
|
||||
|
||||
return [
|
||||
"<item>",
|
||||
`<title>${title}</title>`,
|
||||
`<link>${link}</link>`,
|
||||
`<guid isPermaLink="true">${link}</guid>`,
|
||||
`<description>${description}</description>`,
|
||||
`<pubDate>${pubDate}</pubDate>`,
|
||||
"</item>",
|
||||
].join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<rss version="2.0">',
|
||||
"<channel>",
|
||||
"<title>Gaertan Art - Latest Artworks</title>",
|
||||
`<link>${BASE_URL}</link>`,
|
||||
"<description>Ten newest artworks from Gaertan Art.</description>",
|
||||
`<lastBuildDate>${lastBuildDate}</lastBuildDate>`,
|
||||
itemXml,
|
||||
"</channel>",
|
||||
"</rss>",
|
||||
].join("");
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -3,12 +3,20 @@ import {
|
||||
siLinktree,
|
||||
siMastodon,
|
||||
siPaypal,
|
||||
siRss,
|
||||
siTelegram,
|
||||
siTwitch,
|
||||
type SimpleIcon,
|
||||
} from "simple-icons";
|
||||
|
||||
type SocialKey = "paypal" | "telegram" | "mastodon" | "bluesky" | "linktree" | "twitch";
|
||||
type SocialKey =
|
||||
| "paypal"
|
||||
| "telegram"
|
||||
| "mastodon"
|
||||
| "bluesky"
|
||||
| "linktree"
|
||||
| "twitch"
|
||||
| "rss";
|
||||
|
||||
const SOCIALS: Record<
|
||||
SocialKey,
|
||||
@ -43,7 +51,12 @@ const SOCIALS: Record<
|
||||
label: "Twitch",
|
||||
icon: siTwitch,
|
||||
href: "https://www.twitch.tv/gaertan_art",
|
||||
}
|
||||
},
|
||||
rss: {
|
||||
label: "RSS",
|
||||
icon: siRss,
|
||||
href: `/rss.xml`,
|
||||
},
|
||||
};
|
||||
|
||||
function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
||||
@ -54,13 +67,22 @@ function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
||||
className="h-5 w-5 fill-current"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
// biome-ignore lint: lint/security/noDangerouslySetInnerHtml
|
||||
dangerouslySetInnerHTML={{ __html: icon.svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SocialLinks({
|
||||
items = ["paypal", "telegram", "mastodon", "bluesky", "linktree", "twitch"],
|
||||
items = [
|
||||
"paypal",
|
||||
"telegram",
|
||||
"mastodon",
|
||||
"bluesky",
|
||||
"linktree",
|
||||
"twitch",
|
||||
"rss",
|
||||
],
|
||||
size = "md",
|
||||
}: {
|
||||
items?: SocialKey[];
|
||||
@ -99,4 +121,4 @@ export function SocialLinks({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
76
src/components/animalStudies/AnimalStudiesGallery.tsx
Normal file
76
src/components/animalStudies/AnimalStudiesGallery.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { AnimalStudiesCursor } from "@/actions/animalStudies/getAnimalStudiesPage";
|
||||
import { getAnimalStudiesPage } from "@/actions/animalStudies/getAnimalStudiesPage";
|
||||
import JustifiedGallery, { type JustifiedGalleryItem } from "@/components/gallery/JustifiedGallery";
|
||||
|
||||
export default function AnimalStudiesGallery({
|
||||
tagSlugs,
|
||||
}: {
|
||||
tagSlugs: string[];
|
||||
}) {
|
||||
const [items, setItems] = useState<JustifiedGalleryItem[]>([]);
|
||||
const [cursor, setCursor] = useState<AnimalStudiesCursor>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inFlight = useRef(false);
|
||||
|
||||
// Reset when tag filter changes (component key may already remount, but keep it safe)
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setCursor(null);
|
||||
setDone(false);
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (inFlight.current || done) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await getAnimalStudiesPage({
|
||||
take: 60,
|
||||
cursor,
|
||||
tagSlugs,
|
||||
});
|
||||
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((x) => x.id));
|
||||
const next = res.items.filter((x) => !seen.has(x.id));
|
||||
return prev.concat(next);
|
||||
});
|
||||
|
||||
setCursor(res.nextCursor);
|
||||
if (!res.nextCursor) setDone(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [cursor, done, tagSlugs]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMore();
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<JustifiedGallery
|
||||
items={items}
|
||||
hrefFrom="animal-studies"
|
||||
showCaption
|
||||
targetRowHeight={160}
|
||||
targetRowHeightMobile={160}
|
||||
maxRowHeight={300}
|
||||
maxRowItems={5}
|
||||
maxRowItemsMobile={1}
|
||||
gap={12}
|
||||
onLoadMore={done ? undefined : () => void loadMore()}
|
||||
hasMore={!done}
|
||||
isLoadingMore={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
import { ArtworkImageCard } from "./ArtworkImageCard";
|
||||
|
||||
type ArtworkGalleryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
altText: string | null;
|
||||
okLabL: number | null;
|
||||
file: { fileKey: string };
|
||||
metadata: { width: number; height: number } | null;
|
||||
tags: { id: string; name: string }[];
|
||||
colors: { color: { hex: string | null } }[];
|
||||
};
|
||||
|
||||
type FitMode =
|
||||
| { mode: "fixedWidth"; width: number } // height varies
|
||||
| { mode: "fixedHeight"; height: number }; // width varies
|
||||
|
||||
function getOverlayTextClass(okLabL: number | null | undefined) {
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
function getOverlayBgClass(okLabL: number | null | undefined) {
|
||||
return "bg-black/45";
|
||||
}
|
||||
|
||||
type OpenSheet = "alt" | "tags" | null;
|
||||
|
||||
const BUTTON_BAR_HEIGHT = 36;
|
||||
|
||||
export default function ArtworkThumbGallery({
|
||||
items,
|
||||
hrefBase = "/artworks",
|
||||
fit = { mode: "fixedWidth", width: 400 },
|
||||
}: {
|
||||
items: ArtworkGalleryItem[];
|
||||
hrefBase?: string;
|
||||
fit?: FitMode;
|
||||
}) {
|
||||
const [openSheet, setOpenSheet] = React.useState<Record<string, OpenSheet>>({});
|
||||
|
||||
const toggleSheet = (id: string, which: Exclude<OpenSheet, null>) => {
|
||||
setOpenSheet((prev) => {
|
||||
const current = prev[id] ?? null;
|
||||
// toggle off if same, switch if different
|
||||
return { ...prev, [id]: current === which ? null : which };
|
||||
});
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-muted-foreground italic">No artworks found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-3.5 justify-center"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{items.map((a) => {
|
||||
const textClass = getOverlayTextClass(a.okLabL);
|
||||
const bgClass = getOverlayBgClass(a.okLabL);
|
||||
|
||||
const w = a.metadata?.width ?? 4;
|
||||
const h = a.metadata?.height ?? 3;
|
||||
|
||||
const tileStyle: React.CSSProperties =
|
||||
fit.mode === "fixedWidth"
|
||||
? { aspectRatio: `${w} / ${h}` }
|
||||
: { height: fit.height, aspectRatio: `${w} / ${h}` };
|
||||
|
||||
const sheet = openSheet[a.id] ?? null;
|
||||
|
||||
return (
|
||||
<div key={a.id} className="w-full" style={tileStyle}>
|
||||
<div className="relative h-full w-full">
|
||||
<ArtworkImageCard
|
||||
mode="tile"
|
||||
href={`${hrefBase}/single/${a.id}?from=animal-studies`}
|
||||
src={`/api/image/resized/${a.file.fileKey}.webp`}
|
||||
alt={a.altText ?? a.name ?? "Artwork"}
|
||||
width={a.metadata?.width ?? 0}
|
||||
height={a.metadata?.height ?? 0}
|
||||
aspectRatio={`${w} / ${h}`}
|
||||
className="h-full w-full rounded-md"
|
||||
imageClassName="object-cover"
|
||||
style={{ ["--dom" as any]: a.colors[0]?.color?.hex ?? "#999999", }}
|
||||
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 768px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
|
||||
{/* Title overlay (restored) */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 right-0 top-0 px-3 py-2",
|
||||
bgClass,
|
||||
"backdrop-blur-[1px]"
|
||||
)}
|
||||
>
|
||||
<div className={cn("truncate text-sm font-medium", textClass)}>{a.name}</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom reserved bar (if you need it later) */}
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 z-20 flex items-center justify-between px-2"
|
||||
style={{ height: BUTTON_BAR_HEIGHT }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,7 +7,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
type Timelapse = {
|
||||
s3Key: string;
|
||||
@ -25,11 +25,8 @@ export default function ArtworkTimelapseViewer({
|
||||
artworkName?: string | null;
|
||||
trigger: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// IMPORTANT:
|
||||
// This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys.
|
||||
// If your route expects a different format, adjust this in one place.
|
||||
const src = `/api/image/${encodeURI(timelapse.s3Key)}`;
|
||||
|
||||
// Minimal empty captions track (satisfies jsx-a11y/media-has-caption)
|
||||
@ -46,7 +43,6 @@ export default function ArtworkTimelapseViewer({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Only render video when open (prevents unnecessary network / CPU). */}
|
||||
{open ? (
|
||||
<div className="space-y-2">
|
||||
<video
|
||||
@ -61,7 +57,7 @@ export default function ArtworkTimelapseViewer({
|
||||
</video>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{timelapse.fileName ? timelapse.fileName : timelapse.s3Key}
|
||||
{/* {timelapse.fileName ? timelapse.fileName : timelapse.s3Key} */}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -5,8 +5,9 @@ import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
const FROM_TO_PATH: Record<string, string> = {
|
||||
portfolio: "/portfolio",
|
||||
"animal-studies": "/animal-studies",
|
||||
portfolio: "/artworks",
|
||||
"animal-studies": "/artworks/animalstudies",
|
||||
"animal-index": "/artworks/animalstudies/index"
|
||||
};
|
||||
|
||||
export function ContextBackButton() {
|
||||
|
||||
@ -26,6 +26,7 @@ export default function RawCloseButton({ targetHref }: RawCloseButtonProps) {
|
||||
onClick={() => router.push(targetHref)}
|
||||
className="absolute top-4 right-4 z-50 rounded-md bg-background/80 p-2 hover:bg-background/60 transition"
|
||||
title="Close full view (ESC)"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { FilterIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "../ui/label";
|
||||
|
||||
type Tag = {
|
||||
id: string;
|
||||
@ -24,7 +25,6 @@ type Tag = {
|
||||
slug: string;
|
||||
sortIndex: number;
|
||||
parentId: string | null;
|
||||
// these may exist, but we do NOT rely on them:
|
||||
parent?: { id: string; name: string; slug: string; sortIndex: number } | null;
|
||||
children?: { id: string; name: string; slug: string; sortIndex: number; parentId: string | null }[];
|
||||
};
|
||||
@ -52,21 +52,19 @@ export default function TagFilterDialog({
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState<string[]>(() => selectedTagSlugs);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<string[]>(() => selectedTagSlugs);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setDraft(selectedTagSlugs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTagSlugs.join(",")]);
|
||||
}, [selectedTagSlugs]);
|
||||
|
||||
const hasDraft = draft.length > 0;
|
||||
const selectedSet = React.useMemo(() => new Set(draft), [draft]);
|
||||
const selectedSet = useMemo(() => new Set(draft), [draft]);
|
||||
|
||||
const byId = React.useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
||||
const byId = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
||||
|
||||
// Build children mapping from the flat list: parentId -> Tag[]
|
||||
const childrenByParentId = React.useMemo(() => {
|
||||
const childrenByParentId = useMemo(() => {
|
||||
const map = new Map<string, Tag[]>();
|
||||
for (const t of tags) {
|
||||
if (!t.parentId) continue;
|
||||
@ -74,21 +72,20 @@ export default function TagFilterDialog({
|
||||
arr.push(t);
|
||||
map.set(t.parentId, arr);
|
||||
}
|
||||
// sort each child list
|
||||
for (const [k, arr] of map) {
|
||||
map.set(k, arr.slice().sort(sortTags));
|
||||
}
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const rootGroups = React.useMemo(() => {
|
||||
const rootGroups = useMemo(() => {
|
||||
return tags
|
||||
.filter((t) => t.parentId === null)
|
||||
.slice()
|
||||
.sort(sortTags);
|
||||
}, [tags]);
|
||||
|
||||
const orphanChildren = React.useMemo(() => {
|
||||
const orphanChildren = useMemo(() => {
|
||||
return tags
|
||||
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
|
||||
.slice()
|
||||
@ -101,7 +98,6 @@ export default function TagFilterDialog({
|
||||
const s = new Set(prev);
|
||||
if (next) {
|
||||
s.add(parent.slug);
|
||||
// when selecting parent, remove child selections (redundant)
|
||||
for (const c of children) s.delete(c.slug);
|
||||
} else {
|
||||
s.delete(parent.slug);
|
||||
@ -181,18 +177,15 @@ export default function TagFilterDialog({
|
||||
return (
|
||||
<div key={p.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<Label className="flex cursor-pointer items-center gap-3">
|
||||
<Checkbox
|
||||
checked={parentSelected}
|
||||
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{p.name}</div>
|
||||
{/* <div className="text-xs text-muted-foreground">
|
||||
{children.length ? "Parent tag" : "Tag"}
|
||||
</div> */}
|
||||
</div>
|
||||
</label>
|
||||
</Label>
|
||||
|
||||
<Badge variant={parentSelected ? "default" : "outline"}>
|
||||
{children.length} sub
|
||||
@ -206,7 +199,7 @@ export default function TagFilterDialog({
|
||||
const disabled = parentSelected;
|
||||
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border px-3 py-2",
|
||||
@ -220,7 +213,7 @@ export default function TagFilterDialog({
|
||||
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm">{c.name}</span>
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -231,16 +224,11 @@ export default function TagFilterDialog({
|
||||
|
||||
{orphanChildren.length ? (
|
||||
<div className="rounded-lg border p-4">
|
||||
{/* <div className="mb-2 font-medium">Other tags</div> */}
|
||||
{/* <div className="mb-3 text-xs text-muted-foreground">
|
||||
These tags are not currently assigned to a visible parent.
|
||||
</div> */}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{orphanChildren.map((t) => {
|
||||
const checked = selectedSet.has(t.slug);
|
||||
return (
|
||||
<label
|
||||
<Label
|
||||
key={t.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
@ -249,7 +237,7 @@ export default function TagFilterDialog({
|
||||
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-sm">{t.name}</span>
|
||||
</label>
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
||||
import type { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
||||
|
||||
type CommissionTypeWithItems = CommissionType & {
|
||||
options: (CommissionTypeOption & {
|
||||
|
||||
140
src/components/commissions/CommissionCustomCard.tsx
Normal file
140
src/components/commissions/CommissionCustomCard.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import Image from "next/image";
|
||||
|
||||
type CustomCardOption = {
|
||||
id: string;
|
||||
price: number | null;
|
||||
pricePercent: number | null;
|
||||
priceRange: string | null;
|
||||
option: { name: string } | null;
|
||||
};
|
||||
|
||||
type CustomCardExtra = {
|
||||
id: string;
|
||||
price: number | null;
|
||||
pricePercent: number | null;
|
||||
priceRange: string | null;
|
||||
extra: { name: string } | null;
|
||||
};
|
||||
|
||||
export type CommissionCustomCardWithItems = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
referenceImageUrl: string | null;
|
||||
isSpecialOffer: boolean;
|
||||
options: CustomCardOption[];
|
||||
extras: CustomCardExtra[];
|
||||
};
|
||||
|
||||
export function CommissionCustomCard({
|
||||
card,
|
||||
}: {
|
||||
card: CommissionCustomCardWithItems;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<Card className="flex flex-col flex-1 relative overflow-hidden border-2 border-primary/50 shadow-sm">
|
||||
{card.isSpecialOffer ? (
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10">
|
||||
<div className="absolute right-0 top-16 h-7 w-36 origin-top-right translate-x-10 rotate-45 bg-primary text-primary-foreground shadow-md">
|
||||
<span className="flex h-full w-full items-center justify-center text-xs font-semibold uppercase tracking-wide">
|
||||
Special
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle className="text-xl font-bold">{card.name}</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">{card.description}</p>
|
||||
{card.referenceImageUrl ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative overflow-hidden rounded-lg border border-border/60 bg-muted/40"
|
||||
>
|
||||
<Image
|
||||
src={card.referenceImageUrl}
|
||||
alt={`${card.name} reference`}
|
||||
width={800}
|
||||
height={600}
|
||||
sizes="(max-width: 768px) 90vw, 400px"
|
||||
className="h-auto w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-background/70 px-2 py-1 text-xs text-foreground/80">
|
||||
Click to enlarge
|
||||
</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{card.name} reference</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[85vh] max-w-[85vw] items-center justify-center rounded-xl border-border/60 bg-muted p-2 shadow-2xl">
|
||||
<Image
|
||||
src={card.referenceImageUrl}
|
||||
alt={`${card.name} reference`}
|
||||
width={1600}
|
||||
height={1200}
|
||||
sizes="85vw"
|
||||
className="h-auto max-h-[85vh] w-auto max-w-[85vw] rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col justify-start gap-4">
|
||||
<div>
|
||||
<h4 className="font-semibold">Options</h4>
|
||||
<ul className="pl-4 list-disc">
|
||||
{card.options.map((option) => (
|
||||
<li key={option.id}>
|
||||
{option.option?.name}:{" "}
|
||||
{option.price && option.price !== 0
|
||||
? `${option.price}€`
|
||||
: option.pricePercent
|
||||
? `+${option.pricePercent}%`
|
||||
: option.priceRange && option.priceRange !== "0–0"
|
||||
? `${option.priceRange}€`
|
||||
: "Included"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{card.extras.length > 0 ? (
|
||||
<h4 className="font-semibold">Extras</h4>
|
||||
) : null}
|
||||
<ul className="pl-4 list-disc">
|
||||
{card.extras.map((extra) => (
|
||||
<li key={extra.id}>
|
||||
{extra.extra?.name}:{" "}
|
||||
{extra.price && extra.price !== 0
|
||||
? `${extra.price}€`
|
||||
: extra.pricePercent
|
||||
? `+${extra.pricePercent}%`
|
||||
: extra.priceRange && extra.priceRange !== "0–0"
|
||||
? `${extra.priceRange}€`
|
||||
: "Included"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,8 +12,11 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
import type {
|
||||
CommissionCustomInput,
|
||||
CommissionCustomCard,
|
||||
CommissionCustomCardExtra,
|
||||
CommissionCustomCardOption,
|
||||
CommissionExtra,
|
||||
CommissionOption,
|
||||
CommissionType,
|
||||
@ -28,7 +31,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
import type * as z from "zod/v4";
|
||||
import { FileDropzone } from "./FileDropzone";
|
||||
|
||||
type CommissionTypeWithRelations = CommissionType & {
|
||||
@ -37,15 +40,40 @@ type CommissionTypeWithRelations = CommissionType & {
|
||||
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
types: CommissionTypeWithRelations[];
|
||||
type CommissionCustomCardWithRelations = CommissionCustomCard & {
|
||||
options: (CommissionCustomCardOption & { option: CommissionOption })[];
|
||||
extras: (CommissionCustomCardExtra & { extra: CommissionExtra })[];
|
||||
};
|
||||
|
||||
export function CommissionOrderForm({ types }: Props) {
|
||||
type Props = {
|
||||
types: CommissionTypeWithRelations[];
|
||||
customCards: CommissionCustomCardWithRelations[];
|
||||
};
|
||||
|
||||
type SelectedOption = {
|
||||
id: string;
|
||||
optionId: string;
|
||||
option: CommissionOption;
|
||||
price: number | null;
|
||||
pricePercent: number | null;
|
||||
priceRange: string | null;
|
||||
};
|
||||
|
||||
type SelectedExtra = {
|
||||
id: string;
|
||||
extraId: string;
|
||||
extra: CommissionExtra;
|
||||
price: number | null;
|
||||
pricePercent: number | null;
|
||||
priceRange: string | null;
|
||||
};
|
||||
|
||||
export function CommissionOrderForm({ types, customCards }: Props) {
|
||||
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
||||
resolver: zodResolver(commissionOrderSchema),
|
||||
defaultValues: {
|
||||
typeId: "",
|
||||
customCardId: "",
|
||||
optionId: "",
|
||||
extraIds: [],
|
||||
customerName: "",
|
||||
@ -59,19 +87,49 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const typeId = useWatch({ control: form.control, name: "typeId" });
|
||||
const customCardId = useWatch({ control: form.control, name: "customCardId" });
|
||||
const optionId = useWatch({ control: form.control, name: "optionId" });
|
||||
const extraIds = useWatch({ control: form.control, name: "extraIds" });
|
||||
|
||||
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
|
||||
const selectedCustomCard = useMemo(
|
||||
() => customCards.find((c) => c.id === customCardId),
|
||||
[customCards, customCardId]
|
||||
);
|
||||
|
||||
const selection = useMemo<{
|
||||
kind: "type" | "custom";
|
||||
name: string;
|
||||
options: SelectedOption[];
|
||||
extras: SelectedExtra[];
|
||||
} | null>(() => {
|
||||
if (selectedCustomCard) {
|
||||
return {
|
||||
kind: "custom",
|
||||
name: selectedCustomCard.name,
|
||||
options: selectedCustomCard.options,
|
||||
extras: selectedCustomCard.extras,
|
||||
};
|
||||
}
|
||||
if (selectedType) {
|
||||
return {
|
||||
kind: "type",
|
||||
name: selectedType.name,
|
||||
options: selectedType.options,
|
||||
extras: selectedType.extras,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [selectedCustomCard, selectedType]);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => selectedType?.options.find((o) => o.optionId === optionId),
|
||||
[selectedType, optionId]
|
||||
() => selection?.options.find((o) => o.optionId === optionId),
|
||||
[selection, optionId]
|
||||
);
|
||||
|
||||
const selectedExtras = useMemo(
|
||||
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
||||
[selectedType, extraIds]
|
||||
() => selection?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
||||
[selection, extraIds]
|
||||
);
|
||||
|
||||
const [minPrice, maxPrice] = useMemo(() => {
|
||||
@ -84,6 +142,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
try {
|
||||
const payload = {
|
||||
typeId: values.typeId || null,
|
||||
customCardId: values.customCardId || null,
|
||||
optionId: values.optionId || null,
|
||||
extraIds: values.extraIds ?? [],
|
||||
customerName: values.customerName,
|
||||
@ -100,6 +159,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
|
||||
form.reset({
|
||||
typeId: "",
|
||||
customCardId: "",
|
||||
optionId: "",
|
||||
extraIds: [],
|
||||
customerName: "",
|
||||
@ -136,7 +196,12 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
key={type.id}
|
||||
type="button"
|
||||
variant={field.value === type.id ? "default" : "outline"}
|
||||
onClick={() => field.onChange(type.id)}
|
||||
onClick={() => {
|
||||
field.onChange(type.id);
|
||||
form.setValue("customCardId", "");
|
||||
form.setValue("optionId", "");
|
||||
form.setValue("extraIds", []);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{type.name}
|
||||
@ -149,7 +214,40 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedType && (
|
||||
{customCards.length > 0 ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCardId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom requests / YCH</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customCards.map((card) => (
|
||||
<Button
|
||||
key={card.id}
|
||||
type="button"
|
||||
variant={field.value === card.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
field.onChange(card.id);
|
||||
form.setValue("typeId", "");
|
||||
form.setValue("optionId", "");
|
||||
form.setValue("extraIds", []);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{card.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selection && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -159,7 +257,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormLabel>Base Option</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-1">
|
||||
{selectedType.options.map((opt) => (
|
||||
{selection.options.map((opt) => (
|
||||
<label key={opt.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
@ -186,7 +284,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormLabel>Extras</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-1">
|
||||
{selectedType.extras.map((ext) => (
|
||||
{selection.extras.map((ext) => (
|
||||
<label key={ext.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@ -44,7 +44,7 @@ export function FileDropzone({
|
||||
// Allow selecting the same file again later (if user removes and re-adds)
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[append, files, maxFiles, onFilesSelected]
|
||||
[append, files, maxFiles, onFilesSelected],
|
||||
);
|
||||
|
||||
const handleFiles = React.useCallback(
|
||||
@ -54,7 +54,7 @@ export function FileDropzone({
|
||||
if (incoming.length === 0) return;
|
||||
mergeFiles(incoming);
|
||||
},
|
||||
[mergeFiles]
|
||||
[mergeFiles],
|
||||
);
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@ -75,6 +75,7 @@ export function FileDropzone({
|
||||
};
|
||||
|
||||
return (
|
||||
// biome-ignore lint: lint/a11y/useSemanticElements
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@ -87,7 +88,7 @@ export function FileDropzone({
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"w-full border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors",
|
||||
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30"
|
||||
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
|
||||
291
src/components/gallery/JustifiedGallery.tsx
Normal file
291
src/components/gallery/JustifiedGallery.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type JustifiedGalleryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
altText: string | null;
|
||||
fileKey: string;
|
||||
|
||||
/** Intrinsic dimensions of the resized/thumbnail variant */
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
/** Optional: dominant color for hover ring. */
|
||||
dominantHex?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: JustifiedGalleryItem[];
|
||||
hrefFrom: string;
|
||||
hrefBase?: string; // default: "/artworks/single"
|
||||
showCaption?: boolean;
|
||||
|
||||
// infinite scroll
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
isLoadingMore?: boolean;
|
||||
|
||||
// layout tuning
|
||||
targetRowHeight?: number; // desktop
|
||||
targetRowHeightMobile?: number; // <640px
|
||||
maxRowHeight?: number;
|
||||
maxRowItems?: number; // desktop
|
||||
maxRowItemsMobile?: number; // <640px
|
||||
gap?: number; // px
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type RowTile = {
|
||||
item: JustifiedGalleryItem;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
function aspectOf(it: JustifiedGalleryItem) {
|
||||
const w = Math.max(1, it.width);
|
||||
const h = Math.max(1, it.height);
|
||||
return w / h;
|
||||
}
|
||||
|
||||
function normalizeColor(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const v = value.trim();
|
||||
if (!v) return null;
|
||||
if (v.startsWith("#") || v.startsWith("rgb") || v.startsWith("hsl")) return v;
|
||||
const hex = v.replace(/^0x/i, "");
|
||||
if (/^[0-9a-fA-F]{3}$/.test(hex) || /^[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
return `#${hex}`;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
export default function JustifiedGallery({
|
||||
items,
|
||||
hrefFrom,
|
||||
hrefBase = "/artworks/single",
|
||||
showCaption = false,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoadingMore = false,
|
||||
targetRowHeight = 220,
|
||||
targetRowHeightMobile = 160,
|
||||
maxRowHeight = 260,
|
||||
maxRowItems = 5,
|
||||
maxRowItemsMobile = 3,
|
||||
gap = 12,
|
||||
className,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// Measure container width (responsive)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const ro = new ResizeObserver(() => setContainerWidth(el.clientWidth));
|
||||
ro.observe(el);
|
||||
setContainerWidth(el.clientWidth);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Infinite scroll sentinel
|
||||
useEffect(() => {
|
||||
if (!onLoadMore || !hasMore) return;
|
||||
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isLoadingMore) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "900px 0px" },
|
||||
);
|
||||
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [onLoadMore, hasMore, isLoadingMore]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!containerWidth) return [] as RowTile[][];
|
||||
|
||||
const isMobile = containerWidth < 640;
|
||||
const targetH = isMobile ? targetRowHeightMobile : targetRowHeight;
|
||||
const maxItems = isMobile ? maxRowItemsMobile : maxRowItems;
|
||||
|
||||
const rowTiles: RowTile[][] = [];
|
||||
let current: Array<{ item: JustifiedGalleryItem; aspect: number }> = [];
|
||||
let aspectSum = 0;
|
||||
|
||||
const available = containerWidth;
|
||||
|
||||
const flush = () => {
|
||||
if (current.length === 0) return;
|
||||
|
||||
const gaps = gap * (current.length - 1);
|
||||
const widthWithoutGaps = Math.max(0, available - gaps);
|
||||
|
||||
// Compute row height so it exactly fills the row width.
|
||||
const computedH = widthWithoutGaps / aspectSum;
|
||||
const h = Math.min(computedH, maxRowHeight);
|
||||
|
||||
rowTiles.push(
|
||||
current.map((x) => ({
|
||||
item: x.item,
|
||||
h,
|
||||
w: Math.round(x.aspect * h),
|
||||
})),
|
||||
);
|
||||
|
||||
current = [];
|
||||
aspectSum = 0;
|
||||
};
|
||||
|
||||
for (const it of items) {
|
||||
const a = aspectOf(it);
|
||||
|
||||
current.push({ item: it, aspect: a });
|
||||
aspectSum += a;
|
||||
|
||||
// Estimate the row width if we were to keep targetH
|
||||
const estimatedWidth = aspectSum * targetH + gap * (current.length - 1);
|
||||
|
||||
// If we've filled the row (or reached max items) and have at least 2 tiles, flush.
|
||||
if (
|
||||
(estimatedWidth >= available || current.length >= maxItems) &&
|
||||
current.length > 1
|
||||
) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return rowTiles;
|
||||
}, [
|
||||
items,
|
||||
containerWidth,
|
||||
gap,
|
||||
targetRowHeight,
|
||||
targetRowHeightMobile,
|
||||
maxRowHeight,
|
||||
maxRowItems,
|
||||
maxRowItemsMobile,
|
||||
]);
|
||||
|
||||
const getRowKey = useCallback((row: RowTile[]) => {
|
||||
const first = row[0]?.item.id ?? "row";
|
||||
const last = row.at(-1)?.item.id ?? "row";
|
||||
return `${first}-${last}-${row.length}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("mx-auto w-full max-w-6xl", className)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={getRowKey(row)}
|
||||
className="flex justify-center"
|
||||
style={{ gap }}
|
||||
>
|
||||
{row.map((t) => (
|
||||
<GalleryTile
|
||||
key={t.item.id}
|
||||
tile={t}
|
||||
hrefBase={hrefBase}
|
||||
hrefFrom={hrefFrom}
|
||||
showCaption={showCaption}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onLoadMore ? <div ref={sentinelRef} className="h-px w-full" /> : null}
|
||||
{isLoadingMore ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">Loading…</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryTile({
|
||||
tile,
|
||||
hrefBase,
|
||||
hrefFrom,
|
||||
showCaption,
|
||||
}: {
|
||||
tile: RowTile;
|
||||
hrefBase: string;
|
||||
hrefFrom: string;
|
||||
showCaption: boolean;
|
||||
}) {
|
||||
const { item, w, h } = tile;
|
||||
|
||||
const href = `${hrefBase}/${item.id}?from=${encodeURIComponent(hrefFrom)}`;
|
||||
const src = `/api/image/gallery/${item.fileKey}.webp`;
|
||||
|
||||
const style: CSSProperties & { "--dom"?: string } = {};
|
||||
const dom = normalizeColor(item.dominantHex);
|
||||
if (dom) style["--dom"] = dom;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{ width: w, height: h, ...style }}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-lg border bg-background",
|
||||
"transition-shadow hover:shadow-lg",
|
||||
// keep border visible even if theme border is subtle
|
||||
"border-border",
|
||||
)}
|
||||
>
|
||||
{/* Solid vibrant hover ring (no gradient), driven by --dom.
|
||||
Using box-shadow is more reliable than border-color overrides. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 pointer-events-none rounded-lg transition-[box-shadow,opacity] duration-150",
|
||||
// default no ring
|
||||
"shadow-none opacity-0",
|
||||
// on hover show ring
|
||||
"group-hover:shadow-[inset_0_0_0_2px_var(--dom)]",
|
||||
"group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Image
|
||||
src={src}
|
||||
alt={item.altText ?? item.name ?? "Artwork"}
|
||||
width={w}
|
||||
height={h}
|
||||
className="h-full w-full object-cover"
|
||||
// Tiles are thumbnail-ish; bias Next toward small resources.
|
||||
sizes="(max-width: 640px) 90vw, (max-width: 1024px) 50vw, 320px"
|
||||
/>
|
||||
|
||||
{showCaption ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bg-black/60 p-3">
|
||||
<div className="text-sm font-medium text-white line-clamp-1">
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { cn } from "@/lib/utils";
|
||||
// import { Fraunces } from "next/font/google";
|
||||
import localFont from 'next/font/local';
|
||||
import Image from "next/image";
|
||||
|
||||
// const pacifico = Fraunces({ weight: "700", subsets: ["latin"] });
|
||||
|
||||
const myFont = localFont({
|
||||
src: './Echotopia-Regular.woff2',
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import * as React from "react"
|
||||
import type * as React from "react"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
|
||||
@ -12,6 +12,7 @@ const links = [
|
||||
{ href: "/artworks", label: "Portfolio" },
|
||||
{ href: "/artworks/animalstudies", label: "Animal Studies" },
|
||||
{ href: "/commissions", label: "Commissions" },
|
||||
{ href: "/commissions/status", label: "Commission Status" },
|
||||
{ href: "/tos", label: "Terms of Service" },
|
||||
// { href: "/portfolio/artfight", label: "Artfight" },
|
||||
// { href: "/portfolio/minis", label: "Miniatures" },
|
||||
@ -72,4 +73,4 @@ export default function TopNav() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
PortfolioArtworkItem,
|
||||
PortfolioFilters,
|
||||
} from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import { ArtworkImageCard } from "../artworks/ArtworkImageCard";
|
||||
|
||||
type Placement = {
|
||||
id: string;
|
||||
top: number;
|
||||
left: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dominantHex: string;
|
||||
};
|
||||
|
||||
function computeCols(
|
||||
containerW: number,
|
||||
gap: number,
|
||||
minColW: number,
|
||||
maxCols: number
|
||||
) {
|
||||
const cols = Math.max(
|
||||
1,
|
||||
Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap)))
|
||||
);
|
||||
const colW = Math.floor((containerW - gap * (cols - 1)) / cols);
|
||||
return { cols, colW: Math.max(1, colW) };
|
||||
}
|
||||
|
||||
function packStableMasonry(
|
||||
items: PortfolioArtworkItem[],
|
||||
containerW: number,
|
||||
opts: { gap: number; minColW: number; maxCols: number }
|
||||
): { placements: Placement[]; height: number } {
|
||||
const { gap, minColW, maxCols } = opts;
|
||||
if (containerW <= 0 || items.length === 0) return { placements: [], height: 0 };
|
||||
|
||||
const { cols, colW } = computeCols(containerW, gap, minColW, maxCols);
|
||||
const colHeights = Array(cols).fill(0) as number[];
|
||||
const placements: Placement[] = [];
|
||||
|
||||
for (const it of items) {
|
||||
let cBest = 0;
|
||||
for (let c = 1; c < cols; c++) if (colHeights[c] < colHeights[cBest]) cBest = c;
|
||||
|
||||
const ratio = it.thumbH / it.thumbW;
|
||||
const h = Math.round(colW * ratio);
|
||||
|
||||
const top = colHeights[cBest];
|
||||
const left = cBest * (colW + gap);
|
||||
|
||||
placements.push({
|
||||
id: it.id,
|
||||
top,
|
||||
left,
|
||||
w: colW,
|
||||
h,
|
||||
dominantHex: it.dominantHex,
|
||||
});
|
||||
|
||||
colHeights[cBest] = top + h + gap;
|
||||
}
|
||||
|
||||
const height = Math.max(...colHeights) - gap;
|
||||
return { placements, height: Math.max(0, height) };
|
||||
}
|
||||
|
||||
function thumbUrl(fileKey: string) {
|
||||
return `/api/image/resized/${fileKey}.webp`;
|
||||
}
|
||||
|
||||
function useResizeObserverWidth() {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [w, setW] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, w };
|
||||
}
|
||||
|
||||
export default function ColorMasonryGallery({
|
||||
filters,
|
||||
}: {
|
||||
filters: PortfolioFilters;
|
||||
}) {
|
||||
const { ref: containerRef, w: containerW } = useResizeObserverWidth();
|
||||
|
||||
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const inFlight = React.useRef(false);
|
||||
const doneRef = React.useRef(false);
|
||||
doneRef.current = done;
|
||||
|
||||
const cursorRef = React.useRef<Cursor>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems([]);
|
||||
setDone(false);
|
||||
doneRef.current = false;
|
||||
inFlight.current = false;
|
||||
cursorRef.current = null;
|
||||
}, [filters]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (inFlight.current || doneRef.current) return 0;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await getPortfolioArtworksPage({
|
||||
take: 60,
|
||||
cursor: cursorRef.current,
|
||||
filters,
|
||||
onlyPublished: true,
|
||||
});
|
||||
|
||||
// Defensive dedupe: prevents accidental repeats from any future cursor edge case
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((x) => x.id));
|
||||
const next = data.items.filter((x) => !seen.has(x.id));
|
||||
return prev.concat(next);
|
||||
});
|
||||
|
||||
cursorRef.current = data.nextCursor;
|
||||
if (!data.nextCursor) setDone(true);
|
||||
|
||||
return data.items.length;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadMore]);
|
||||
|
||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "900px 0px", threshold: 0.01 }
|
||||
);
|
||||
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const GAP = 14;
|
||||
const MIN_COL_W = 260;
|
||||
const MAX_COLS = 6;
|
||||
|
||||
const { placements, height } = React.useMemo(() => {
|
||||
return packStableMasonry(items, containerW, {
|
||||
gap: GAP,
|
||||
minColW: MIN_COL_W,
|
||||
maxCols: MAX_COLS,
|
||||
});
|
||||
}, [items, containerW]);
|
||||
|
||||
const itemsById = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
<div className="relative w-full" style={{ height }}>
|
||||
{placements.map((p) => {
|
||||
const it = itemsById.get(p.id);
|
||||
if (!it) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
transform: `translate(${p.left}px, ${p.top}px)`,
|
||||
width: p.w,
|
||||
height: p.h,
|
||||
}}
|
||||
>
|
||||
{/* <div style={{ ["--dom" as any]: p.dominantHex }} className="h-full w-full"> */}
|
||||
<ArtworkImageCard
|
||||
mode="tile"
|
||||
href={`/artworks/single/${it.id}?from=portfolio`}
|
||||
src={thumbUrl(it.fileKey)}
|
||||
alt={it.altText ?? it.name}
|
||||
width={Math.max(1, it.thumbW)}
|
||||
height={Math.max(1, it.thumbH)}
|
||||
style={{ ["--dom" as any]: p.dominantHex }}
|
||||
className="w-full h-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
// </div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!done && <div ref={sentinelRef} style={{ height: 1 }} />}
|
||||
{loading && <p className="text-sm text-muted-foreground mt-3">Loading…</p>}
|
||||
{!loading && done && items.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-20">No artworks to display</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { FilterIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function setParam(params: URLSearchParams, key: string, value?: string | null) {
|
||||
if (!value) params.delete(key);
|
||||
@ -16,118 +31,119 @@ export default function PortfolioFiltersBar({ years = [] }: { years?: number[] }
|
||||
const yearParam = sp.get("year") ?? "all";
|
||||
const qParam = sp.get("q") ?? "";
|
||||
|
||||
// Local input state (typing does NOT change URL)
|
||||
const [q, setQ] = React.useState(qParam);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftYear, setDraftYear] = useState<string>(yearParam);
|
||||
const [draftQ, setDraftQ] = useState<string>(qParam);
|
||||
|
||||
// Sync input when navigating back/forward (URL -> input)
|
||||
React.useEffect(() => {
|
||||
setQ(qParam);
|
||||
}, [qParam]);
|
||||
useEffect(() => {
|
||||
setDraftYear(yearParam);
|
||||
setDraftQ(qParam);
|
||||
}, [yearParam, qParam]);
|
||||
|
||||
const pushParams = React.useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
mutate(next);
|
||||
const activeCount = (yearParam !== "all" ? 1 : 0) + (qParam.trim().length ? 1 : 0);
|
||||
|
||||
const nextQs = next.toString();
|
||||
const currQs = sp.toString();
|
||||
if (nextQs === currQs) return; // guard against redundant replaces
|
||||
|
||||
router.replace(nextQs ? `${pathname}?${nextQs}` : pathname, { scroll: false });
|
||||
},
|
||||
[pathname, router, sp]
|
||||
);
|
||||
|
||||
const setYear = (year: "all" | number) => {
|
||||
pushParams((next) => {
|
||||
setParam(next, "year", year === "all" ? null : String(year));
|
||||
});
|
||||
const clearAll = () => {
|
||||
setDraftYear("all");
|
||||
setDraftQ("");
|
||||
};
|
||||
|
||||
const submitSearch = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
pushParams((next) => {
|
||||
setParam(next, "q", trimmed.length ? trimmed : null);
|
||||
});
|
||||
};
|
||||
const apply = () => {
|
||||
const next = new URLSearchParams(sp.toString());
|
||||
|
||||
const clear = () => {
|
||||
setQ("");
|
||||
pushParams((next) => {
|
||||
next.delete("year");
|
||||
next.delete("q");
|
||||
});
|
||||
const year = draftYear.trim();
|
||||
if (!year || year === "all") next.delete("year");
|
||||
else setParam(next, "year", year);
|
||||
|
||||
const q = draftQ.trim();
|
||||
if (!q) next.delete("q");
|
||||
else setParam(next, "q", q);
|
||||
|
||||
const qs = next.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm text-muted-foreground">Year</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setYear("all")}
|
||||
className={[
|
||||
"h-9 rounded-md border px-3 text-sm",
|
||||
yearParam === "all" ? "bg-accent" : "hover:bg-accent/60",
|
||||
].join(" ")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="default" className="h-11 gap-2">
|
||||
<FilterIcon className="h-4 w-4" />
|
||||
Filter
|
||||
{activeCount > 0 ? (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
{years.map((y) => {
|
||||
const active = yearParam === String(y);
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={() => setYear(y)}
|
||||
className={[
|
||||
"h-9 rounded-md border px-3 text-sm",
|
||||
active ? "bg-accent" : "hover:bg-accent/60",
|
||||
].join(" ")}
|
||||
<DialogContent className="p-0 sm:max-w-xl">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle className="flex items-center justify-between gap-3">
|
||||
<span>Filter portfolio</span>
|
||||
{draftYear !== "all" || draftQ.trim().length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
className="gap-2"
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filter by year and search by artwork name or tags.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="max-h-[60vh] px-6 py-4">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio-year">Year</Label>
|
||||
<select
|
||||
id="portfolio-year"
|
||||
value={draftYear}
|
||||
onChange={(e) => setDraftYear(e.target.value)}
|
||||
className="h-11 w-full rounded-md border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="all">All years</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio-q">Search</Label>
|
||||
<Input
|
||||
id="portfolio-q"
|
||||
value={draftQ}
|
||||
onChange={(e) => setDraftQ(e.target.value)}
|
||||
placeholder="Search name or tags"
|
||||
inputMode="search"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-1 sm:max-w-xl"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitSearch(q);
|
||||
}}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">Search (by name or tags)</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="e.g. lizard, monk, fantasy"
|
||||
inputMode="search"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 rounded-md border px-3 text-sm hover:bg-accent"
|
||||
onClick={clear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
131
src/components/portfolio/PortfolioGallery.tsx
Normal file
131
src/components/portfolio/PortfolioGallery.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
PortfolioArtworkItem,
|
||||
PortfolioFilters,
|
||||
} from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import JustifiedGallery, {
|
||||
type JustifiedGalleryItem,
|
||||
} from "@/components/gallery/JustifiedGallery";
|
||||
|
||||
export default function PortfolioGallery({
|
||||
filters,
|
||||
}: {
|
||||
filters: PortfolioFilters;
|
||||
}) {
|
||||
const { year, albumId, q } = filters;
|
||||
|
||||
const queryFilters = useMemo<PortfolioFilters>(
|
||||
() => ({ year, albumId, q }),
|
||||
[year, albumId, q]
|
||||
);
|
||||
const resetKey = useMemo(
|
||||
() => `${year ?? ""}|${albumId ?? ""}|${q ?? ""}`,
|
||||
[year, albumId, q]
|
||||
);
|
||||
|
||||
const [items, setItems] = useState<PortfolioArtworkItem[]>([]);
|
||||
const [done, setDone] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inFlight = useRef(false);
|
||||
const doneRef = useRef(false);
|
||||
doneRef.current = done;
|
||||
const cursorRef = useRef<Cursor>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey == null) return;
|
||||
setItems([]);
|
||||
setDone(false);
|
||||
doneRef.current = false;
|
||||
inFlight.current = false;
|
||||
cursorRef.current = null;
|
||||
}, [resetKey]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (inFlight.current || doneRef.current) return 0;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await getPortfolioArtworksPage({
|
||||
take: 60,
|
||||
cursor: cursorRef.current,
|
||||
filters: queryFilters,
|
||||
onlyPublished: true,
|
||||
});
|
||||
|
||||
// Defensive dedupe
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((x) => x.id));
|
||||
const next = data.items.filter((x) => !seen.has(x.id));
|
||||
return prev.concat(next);
|
||||
});
|
||||
|
||||
cursorRef.current = data.nextCursor;
|
||||
if (!data.nextCursor) setDone(true);
|
||||
|
||||
return data.items.length;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [queryFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMore();
|
||||
}, [loadMore]);
|
||||
|
||||
const galleryItems: JustifiedGalleryItem[] = items.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
altText: it.altText,
|
||||
fileKey: it.fileKey,
|
||||
width: it.thumbW,
|
||||
height: it.thumbH,
|
||||
dominantHex: it.dominantHex,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
// Debug: inspect dominantHex values coming from the server.
|
||||
console.log(
|
||||
"[PortfolioGallery] dominantHex sample",
|
||||
items.slice(0, 5).map((it) => ({
|
||||
id: it.id,
|
||||
dominantHex: it.dominantHex,
|
||||
}))
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
if (!loading && done && galleryItems.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-center py-20">
|
||||
No artworks to display
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<JustifiedGallery
|
||||
items={galleryItems}
|
||||
hrefFrom="portfolio"
|
||||
showCaption={false}
|
||||
targetRowHeight={160}
|
||||
targetRowHeightMobile={160}
|
||||
maxRowHeight={300}
|
||||
maxRowItems={5}
|
||||
maxRowItemsMobile={1}
|
||||
gap={12}
|
||||
onLoadMore={done ? undefined : () => void loadMore()}
|
||||
hasMore={!done}
|
||||
isLoadingMore={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -63,4 +63,5 @@ function AccordionContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input dark:border-input dark:hover:bg-input/60",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
|
||||
@ -14,7 +14,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer border-input dark:bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
||||
@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -1,12 +1,32 @@
|
||||
import * as z from "zod/v4"
|
||||
|
||||
export const commissionOrderSchema = z.object({
|
||||
typeId: z.string().min(1, "Please select a type"),
|
||||
typeId: z.string().optional(),
|
||||
customCardId: z.string().optional(),
|
||||
optionId: z.string().min(1, "Please choose a base option"),
|
||||
extraIds: z.array(z.string()).optional(),
|
||||
customFields: z.record(z.string(), z.any()).optional(),
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
customerName: z.string().min(2, "Enter your name"),
|
||||
customerEmail: z.email("Invalid email"),
|
||||
customerSocials: z.string().optional(),
|
||||
message: z.string().min(5, "Please describe what you want"),
|
||||
}).superRefine((data, ctx) => {
|
||||
const hasType = Boolean(data.typeId && data.typeId.length > 0);
|
||||
const hasCustom = Boolean(data.customCardId && data.customCardId.length > 0);
|
||||
|
||||
if (!hasType && !hasCustom) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["typeId"],
|
||||
message: "Please select a commission type or a custom card",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasType && hasCustom) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["typeId"],
|
||||
message: "Choose either a type or a custom card, not both",
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@ -10,7 +10,7 @@ export function calculatePrice(source: PriceSource, base: number): number {
|
||||
if (source.priceRange) {
|
||||
const parts = source.priceRange.split("–").map(Number)
|
||||
const max = Math.max(...parts)
|
||||
return isNaN(max) ? 0 : max
|
||||
return Number.isNaN(max) ? 0 : max
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@ -39,8 +39,8 @@ export function calculatePriceRange(
|
||||
const min = Number(minStr)
|
||||
const max = Number(maxStr)
|
||||
|
||||
if (!isNaN(min)) minExtra += min
|
||||
if (!isNaN(max)) maxExtra += max
|
||||
if (!Number.isNaN(min)) minExtra += min
|
||||
if (!Number.isNaN(max)) maxExtra += max
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user