Add tags to commssion types and custom types. Add button for example images to cards
This commit is contained in:
@ -47,13 +47,13 @@ model Artwork {
|
|||||||
galleryId String?
|
galleryId String?
|
||||||
gallery Gallery? @relation(fields: [galleryId], references: [id])
|
gallery Gallery? @relation(fields: [galleryId], references: [id])
|
||||||
|
|
||||||
metadata ArtworkMetadata?
|
metadata ArtworkMetadata?
|
||||||
timelapse ArtworkTimelapse?
|
timelapse ArtworkTimelapse?
|
||||||
|
|
||||||
albums Album[]
|
albums Album[]
|
||||||
categories ArtCategory[]
|
categories ArtCategory[]
|
||||||
colors ArtworkColor[]
|
colors ArtworkColor[]
|
||||||
tags Tag[] @relation("ArtworkTags")
|
tags Tag[] @relation("ArtworkTags")
|
||||||
variants FileVariant[]
|
variants FileVariant[]
|
||||||
|
|
||||||
@@index([colorStatus])
|
@@index([colorStatus])
|
||||||
@ -165,12 +165,12 @@ model ArtworkTimelapse {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
artworkId String @unique
|
artworkId String @unique
|
||||||
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
|
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
enabled Boolean @default(false)
|
enabled Boolean @default(false)
|
||||||
|
|
||||||
s3Key String @unique
|
s3Key String @unique
|
||||||
fileName String?
|
fileName String?
|
||||||
mimeType String?
|
mimeType String?
|
||||||
sizeBytes Int?
|
sizeBytes Int?
|
||||||
@ -224,12 +224,13 @@ model Tag {
|
|||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
aliases TagAlias[]
|
aliases TagAlias[]
|
||||||
categoryLinks TagCategory[]
|
categoryLinks TagCategory[]
|
||||||
categoryParents TagCategory[] @relation("TagCategoryParent")
|
categoryParents TagCategory[] @relation("TagCategoryParent")
|
||||||
artworks Artwork[] @relation("ArtworkTags")
|
artworks Artwork[] @relation("ArtworkTags")
|
||||||
commissionTypes CommissionType[] @relation("CommissionTypeTags")
|
commissionTypes CommissionType[] @relation("CommissionTypeTags")
|
||||||
miniatures Miniature[] @relation("MiniatureTags")
|
commissionCustomCards CommissionCustomCard[] @relation("CommissionCustomCardTags")
|
||||||
|
miniatures Miniature[] @relation("MiniatureTags")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TagAlias {
|
model TagAlias {
|
||||||
@ -240,7 +241,7 @@ model TagAlias {
|
|||||||
alias String @unique
|
alias String @unique
|
||||||
|
|
||||||
tagId String
|
tagId String
|
||||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([tagId, alias])
|
@@unique([tagId, alias])
|
||||||
@@index([alias])
|
@@index([alias])
|
||||||
@ -310,13 +311,14 @@ model CommissionCustomCard {
|
|||||||
|
|
||||||
name String
|
name String
|
||||||
|
|
||||||
description String?
|
description String?
|
||||||
referenceImageUrl String?
|
referenceImageUrl String?
|
||||||
isVisible Boolean @default(true)
|
isVisible Boolean @default(true)
|
||||||
isSpecialOffer Boolean @default(false)
|
isSpecialOffer Boolean @default(false)
|
||||||
|
|
||||||
options CommissionCustomCardOption[]
|
tags Tag[] @relation("CommissionCustomCardTags")
|
||||||
extras CommissionCustomCardExtra[]
|
options CommissionCustomCardOption[]
|
||||||
|
extras CommissionCustomCardExtra[]
|
||||||
requests CommissionRequest[]
|
requests CommissionRequest[]
|
||||||
|
|
||||||
@@index([isVisible, sortIndex])
|
@@index([isVisible, sortIndex])
|
||||||
@ -332,9 +334,9 @@ model CommissionOption {
|
|||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
types CommissionTypeOption[]
|
types CommissionTypeOption[]
|
||||||
customCards CommissionCustomCardOption[]
|
customCards CommissionCustomCardOption[]
|
||||||
requests CommissionRequest[]
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionTypeOption {
|
model CommissionTypeOption {
|
||||||
@ -366,8 +368,8 @@ model CommissionExtra {
|
|||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
requests CommissionRequest[]
|
requests CommissionRequest[]
|
||||||
types CommissionTypeExtra[]
|
types CommissionTypeExtra[]
|
||||||
customCards CommissionCustomCardExtra[]
|
customCards CommissionCustomCardExtra[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,12 +477,12 @@ model CommissionRequest {
|
|||||||
userAgent String?
|
userAgent String?
|
||||||
customFields Json?
|
customFields Json?
|
||||||
|
|
||||||
optionId String?
|
optionId String?
|
||||||
typeId String?
|
typeId String?
|
||||||
customCardId String?
|
customCardId String?
|
||||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||||
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
|
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
|
||||||
|
|
||||||
extras CommissionExtra[]
|
extras CommissionExtra[]
|
||||||
files CommissionRequestFile[]
|
files CommissionRequestFile[]
|
||||||
@ -491,9 +493,9 @@ model CommissionGuidelines {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
markdown String
|
markdown String
|
||||||
exampleImageUrl String?
|
exampleImageUrl String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
}
|
}
|
||||||
|
|||||||
173
src/actions/portfolio/getTaggedArtworksPage.ts
Normal file
173
src/actions/portfolio/getTaggedArtworksPage.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import type { Prisma } from "@/generated/prisma/browser";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export type Cursor = {
|
||||||
|
afterSortKey: number | null;
|
||||||
|
afterId: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type TaggedArtworkItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
altText: string | null;
|
||||||
|
sortKey: number | null;
|
||||||
|
year: number | null;
|
||||||
|
fileKey: string;
|
||||||
|
thumbW: number;
|
||||||
|
thumbH: number;
|
||||||
|
dominantHex: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariantPick = { type: string; width: number; height: number };
|
||||||
|
function pickVariant(variants: VariantPick[], type: string) {
|
||||||
|
return variants.find((v) => v.type === type) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaggedArtworksPage(args: {
|
||||||
|
take?: number;
|
||||||
|
cursor?: Cursor;
|
||||||
|
tagSlugs: string[];
|
||||||
|
onlyPublished?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
items: TaggedArtworkItem[];
|
||||||
|
nextCursor: Cursor;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const { take = 60, cursor = null, tagSlugs, onlyPublished = true } = args;
|
||||||
|
|
||||||
|
const filteredSlugs = tagSlugs.map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (filteredSlugs.length === 0) {
|
||||||
|
return { items: [], nextCursor: null, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseWhere: Prisma.ArtworkWhereInput = {
|
||||||
|
...(onlyPublished ? { published: true } : {}),
|
||||||
|
tags: { some: { slug: { in: filteredSlugs } } },
|
||||||
|
variants: { some: { type: "thumbnail" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = await prisma.artwork.count({ where: baseWhere });
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
altText: true,
|
||||||
|
year: true,
|
||||||
|
sortKey: true,
|
||||||
|
file: { select: { fileKey: true } },
|
||||||
|
variants: {
|
||||||
|
where: { type: "thumbnail" },
|
||||||
|
select: { type: true, width: true, height: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
where: { type: "Vibrant" },
|
||||||
|
select: { color: { select: { hex: true } } },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
} satisfies Prisma.ArtworkSelect;
|
||||||
|
|
||||||
|
type ArtworkRow = Prisma.ArtworkGetPayload<{ select: typeof select }>;
|
||||||
|
|
||||||
|
const mapRow = (r: ArtworkRow): TaggedArtworkItem | null => {
|
||||||
|
const thumb = pickVariant(r.variants, "thumbnail");
|
||||||
|
if (!thumb?.width || !thumb?.height) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
altText: r.altText ?? null,
|
||||||
|
sortKey: r.sortKey ?? null,
|
||||||
|
year: r.year ?? null,
|
||||||
|
fileKey: r.file.fileKey,
|
||||||
|
thumbW: thumb.width,
|
||||||
|
thumbH: thumb.height,
|
||||||
|
dominantHex: r.colors[0]?.color?.hex ?? "#999999",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let items: TaggedArtworkItem[] = [];
|
||||||
|
let nextCursor: Cursor = null;
|
||||||
|
|
||||||
|
const inNullSegment = cursor?.afterSortKey === null;
|
||||||
|
|
||||||
|
if (!inNullSegment) {
|
||||||
|
const whereA: Prisma.ArtworkWhereInput = {
|
||||||
|
AND: [baseWhere, { sortKey: { not: null } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cursor?.afterSortKey != null) {
|
||||||
|
const sk = Number(cursor.afterSortKey);
|
||||||
|
whereA.OR = [
|
||||||
|
{ sortKey: { gt: sk } },
|
||||||
|
{ AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsA = await prisma.artwork.findMany({
|
||||||
|
where: whereA,
|
||||||
|
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||||
|
take: Math.min(take, 200),
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
items = rowsA.map(mapRow).filter((x): x is TaggedArtworkItem => x !== null);
|
||||||
|
|
||||||
|
if (items.length >= take) {
|
||||||
|
const last = items.at(-1);
|
||||||
|
if (!last || last.sortKey == null) {
|
||||||
|
return { items, nextCursor: null, total };
|
||||||
|
}
|
||||||
|
nextCursor = { afterSortKey: last.sortKey, afterId: last.id };
|
||||||
|
return { items, nextCursor, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = take - items.length;
|
||||||
|
|
||||||
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
|
AND: [baseWhere, { sortKey: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowsB = await prisma.artwork.findMany({
|
||||||
|
where: whereB,
|
||||||
|
orderBy: [{ id: "asc" }],
|
||||||
|
take: Math.min(remaining, 200),
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
const more = rowsB.map(mapRow).filter((x): x is TaggedArtworkItem => x !== null);
|
||||||
|
items = items.concat(more);
|
||||||
|
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
nextCursor =
|
||||||
|
items.length < take || !last
|
||||||
|
? null
|
||||||
|
: { afterSortKey: last.sortKey ?? null, afterId: last.id };
|
||||||
|
|
||||||
|
return { items, nextCursor, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
|
AND: [baseWhere, { sortKey: null }],
|
||||||
|
...(cursor ? { id: { gt: cursor.afterId } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowsB = await prisma.artwork.findMany({
|
||||||
|
where: whereB,
|
||||||
|
orderBy: [{ id: "asc" }],
|
||||||
|
take: Math.min(take, 200),
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
items = rowsB.map(mapRow).filter((x): x is TaggedArtworkItem => x !== null);
|
||||||
|
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
nextCursor =
|
||||||
|
items.length < take || !last
|
||||||
|
? null
|
||||||
|
: { afterSortKey: null, afterId: last.id };
|
||||||
|
|
||||||
|
return { items, nextCursor, total };
|
||||||
|
}
|
||||||
48
src/app/(normal)/artworks/tagged/page.tsx
Normal file
48
src/app/(normal)/artworks/tagged/page.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import TaggedGallery from "@/components/portfolio/TaggedGallery";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function parseTagsParam(tags: string | string[] | undefined): string[] {
|
||||||
|
if (!tags) return [];
|
||||||
|
const raw = Array.isArray(tags) ? tags.join(",") : tags;
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TaggedPortfolioPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: { tags?: string | string[] };
|
||||||
|
}) {
|
||||||
|
const { tags } = await searchParams;
|
||||||
|
const selectedTagSlugs = parseTagsParam(tags);
|
||||||
|
|
||||||
|
const tagsSelected = selectedTagSlugs.length
|
||||||
|
? await prisma.tag.findMany({
|
||||||
|
where: { slug: { in: selectedTagSlugs } },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
|
{tagsSelected.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
List of artworks tagged with:
|
||||||
|
{tagsSelected.map((t) => (
|
||||||
|
<span key={t.name.toLowerCase()}> {t.name.toLowerCase()}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : "No tags selected"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<TaggedGallery tagSlugs={selectedTagSlugs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,7 +19,11 @@ export default async function CommissionsPage() {
|
|||||||
include: {
|
include: {
|
||||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||||
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
|
customInputs: {
|
||||||
|
include: { customInput: true },
|
||||||
|
orderBy: { sortIndex: "asc" },
|
||||||
|
},
|
||||||
|
tags: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
}),
|
}),
|
||||||
@ -28,6 +32,7 @@ export default async function CommissionsPage() {
|
|||||||
include: {
|
include: {
|
||||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||||
|
tags: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
}),
|
}),
|
||||||
@ -49,7 +54,7 @@ export default async function CommissionsPage() {
|
|||||||
{guidelines?.exampleImageUrl ? (
|
{guidelines?.exampleImageUrl ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="secondary">View example</Button>
|
<Button variant="secondary">View type example</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
@ -80,7 +85,10 @@ export default async function CommissionsPage() {
|
|||||||
<CommissionGuidelines />
|
<CommissionGuidelines />
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 id="commission-request-form" className="text-2xl font-semibold scroll-mt-24">
|
<h2
|
||||||
|
id="commission-request-form"
|
||||||
|
className="text-2xl font-semibold scroll-mt-24"
|
||||||
|
>
|
||||||
Request a Commission
|
Request a Commission
|
||||||
</h2>
|
</h2>
|
||||||
<CommissionOrderForm types={commissions} customCards={customCards} />
|
<CommissionOrderForm types={commissions} customCards={customCards} />
|
||||||
|
|||||||
@ -1,52 +1,43 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Button } from "@/components/ui/button";
|
||||||
import type { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type {
|
||||||
|
CommissionExtra,
|
||||||
|
CommissionOption,
|
||||||
|
CommissionType,
|
||||||
|
CommissionTypeExtra,
|
||||||
|
CommissionTypeOption,
|
||||||
|
Tag,
|
||||||
|
} from "@/generated/prisma/client";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type CommissionTypeWithItems = CommissionType & {
|
type CommissionTypeWithItems = CommissionType & {
|
||||||
options: (CommissionTypeOption & {
|
options: (CommissionTypeOption & {
|
||||||
option: CommissionOption | null
|
option: CommissionOption | null;
|
||||||
})[]
|
})[];
|
||||||
extras: (CommissionTypeExtra & {
|
extras: (CommissionTypeExtra & {
|
||||||
extra: CommissionExtra | null
|
extra: CommissionExtra | null;
|
||||||
})[]
|
})[];
|
||||||
}
|
tags: Tag[];
|
||||||
|
};
|
||||||
export function CommissionCard({ commission }: { commission: CommissionTypeWithItems }) {
|
|
||||||
// const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
|
export function CommissionCard({
|
||||||
|
commission,
|
||||||
|
}: {
|
||||||
|
commission: CommissionTypeWithItems;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<Card className="flex flex-col flex-1">
|
<Card className="flex flex-col flex-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl font-bold">{commission.name}</CardTitle>
|
<CardTitle className="text-xl font-bold">{commission.name}</CardTitle>
|
||||||
<p className="text-muted-foreground text-sm">{commission.description}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{commission.description}
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-col justify-start gap-4">
|
<CardContent className="flex flex-col flex-1 justify-start gap-4">
|
||||||
{/* {examples && examples.length > 0 && (
|
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
|
||||||
<CollapsibleTrigger className="text-sm underline text-muted-foreground">
|
|
||||||
{open ? "Hide Examples" : "See Examples"}
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent asChild>
|
|
||||||
<div className="overflow-hidden transition-all data-[state=closed]:max-h-0 data-[state=open]:max-h-[300px]">
|
|
||||||
<div className="flex gap-2 mt-2 overflow-x-auto">
|
|
||||||
{examples.map((src, idx) => (
|
|
||||||
<Image
|
|
||||||
key={src + idx}
|
|
||||||
src={src}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
alt={`${type.name} example ${idx + 1}`}
|
|
||||||
className="h-24 w-auto rounded border"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
)} */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold">Options</h4>
|
<h4 className="font-semibold">Options</h4>
|
||||||
<ul className="pl-4 list-disc">
|
<ul className="pl-4 list-disc">
|
||||||
@ -66,7 +57,9 @@ export function CommissionCard({ commission }: { commission: CommissionTypeWithI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{commission.extras.length > 0 && <h4 className="font-semibold">Extras</h4>}
|
{commission.extras.length > 0 && (
|
||||||
|
<h4 className="font-semibold">Extras</h4>
|
||||||
|
)}
|
||||||
<ul className="pl-4 list-disc">
|
<ul className="pl-4 list-disc">
|
||||||
{commission.extras.map((extra) => (
|
{commission.extras.map((extra) => (
|
||||||
<li key={extra.id}>
|
<li key={extra.id}>
|
||||||
@ -82,16 +75,21 @@ export function CommissionCard({ commission }: { commission: CommissionTypeWithI
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className="flex flex-wrap gap-2">
|
|
||||||
{commission.extras.map((extra) => (
|
|
||||||
<Badge variant="outline" key={extra.id}>
|
|
||||||
{extra.extra?.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div> */}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
{commission.tags.length > 0 ? (
|
||||||
|
<div className="mt-auto px-6 pb-6">
|
||||||
|
<Link
|
||||||
|
href={`/portfolio/tagged?tags=${encodeURIComponent(
|
||||||
|
commission.tags.map((t) => t.slug).join(","),
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" className="w-full">
|
||||||
|
View example artworks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -8,8 +9,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import type { Tag } from "@/generated/prisma/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type CustomCardOption = {
|
type CustomCardOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -33,6 +36,7 @@ export type CommissionCustomCardWithItems = {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
referenceImageUrl: string | null;
|
referenceImageUrl: string | null;
|
||||||
isSpecialOffer: boolean;
|
isSpecialOffer: boolean;
|
||||||
|
tags: Tag[];
|
||||||
options: CustomCardOption[];
|
options: CustomCardOption[];
|
||||||
extras: CustomCardExtra[];
|
extras: CustomCardExtra[];
|
||||||
};
|
};
|
||||||
@ -50,7 +54,7 @@ export function CommissionCustomCard({
|
|||||||
"flex flex-col h-full relative shadow-sm",
|
"flex flex-col h-full relative shadow-sm",
|
||||||
card.isSpecialOffer
|
card.isSpecialOffer
|
||||||
? "border-2 border-primary/50"
|
? "border-2 border-primary/50"
|
||||||
: "border-border"
|
: "border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{card.isSpecialOffer ? (
|
{card.isSpecialOffer ? (
|
||||||
@ -143,6 +147,19 @@ export function CommissionCustomCard({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
{card.tags.length > 0 ? (
|
||||||
|
<div className="mt-auto px-6 pb-6">
|
||||||
|
<Link
|
||||||
|
href={`/portfolio/tagged?tags=${encodeURIComponent(
|
||||||
|
card.tags.map((t) => t.slug).join(","),
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" className="w-full">
|
||||||
|
View example artworks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
116
src/components/portfolio/TaggedGallery.tsx
Normal file
116
src/components/portfolio/TaggedGallery.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Cursor,
|
||||||
|
TaggedArtworkItem,
|
||||||
|
} from "@/actions/portfolio/getTaggedArtworksPage";
|
||||||
|
import { getTaggedArtworksPage } from "@/actions/portfolio/getTaggedArtworksPage";
|
||||||
|
import JustifiedGallery, {
|
||||||
|
type JustifiedGalleryItem,
|
||||||
|
} from "@/components/gallery/JustifiedGallery";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export default function TaggedGallery({ tagSlugs }: { tagSlugs: string[] }) {
|
||||||
|
const normalizedSlugs = useMemo(
|
||||||
|
() => tagSlugs.map((s) => s.trim()).filter(Boolean),
|
||||||
|
[tagSlugs],
|
||||||
|
);
|
||||||
|
const resetKey = useMemo(
|
||||||
|
() => normalizedSlugs.slice().sort().join(","),
|
||||||
|
[normalizedSlugs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<TaggedArtworkItem[]>([]);
|
||||||
|
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(() => {
|
||||||
|
setItems([]);
|
||||||
|
setDone(false);
|
||||||
|
doneRef.current = false;
|
||||||
|
inFlight.current = false;
|
||||||
|
cursorRef.current = null;
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (inFlight.current || doneRef.current || normalizedSlugs.length === 0)
|
||||||
|
return 0;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getTaggedArtworksPage({
|
||||||
|
take: 60,
|
||||||
|
cursor: cursorRef.current,
|
||||||
|
tagSlugs: normalizedSlugs,
|
||||||
|
onlyPublished: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}, [normalizedSlugs]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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}
|
||||||
|
debug={false}
|
||||||
|
targetRowHeight={160}
|
||||||
|
targetRowHeightMobile={160}
|
||||||
|
maxRowHeight={300}
|
||||||
|
maxRowItems={5}
|
||||||
|
maxRowItemsMobile={1}
|
||||||
|
gap={12}
|
||||||
|
gapBreakpoints={[
|
||||||
|
{ maxWidth: 685, gap: 6 },
|
||||||
|
{ maxWidth: 910, gap: 8 },
|
||||||
|
{ maxWidth: 1130, gap: 10 },
|
||||||
|
]}
|
||||||
|
onLoadMore={done ? undefined : () => void loadMore()}
|
||||||
|
hasMore={!done}
|
||||||
|
isLoadingMore={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user