diff --git a/TODO.md b/TODO.md index 96111e7..f1d36c3 100644 --- a/TODO.md +++ b/TODO.md @@ -168,9 +168,9 @@ This file is the single source of truth for roadmap and delivery progress. - [~] [P1] Dynamic page rendering from CMS page entities - [~] [P1] Navigation rendering from managed menu structure -- [ ] [P1] Media entity rendering with enrichment data -- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls -- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot +- [~] [P1] Media entity rendering with enrichment data +- [~] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls +- [~] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot - [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels) - [ ] [P2] Artwork views and listing filters - [~] [P1] Commission request submission flow @@ -324,6 +324,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior. - [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback. - [2026-02-12] Public rendering integration advanced with locale-aware navigation/news translations and a new public commission request entry route (`/[locale]/commissions`) that creates/reuses customer records and opens a `new` commission. +- [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering. ## How We Use This File diff --git a/apps/web/package.json b/apps/web/package.json index 6eec5e0..58beab1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@aws-sdk/client-s3": "3.988.0", "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/i18n": "workspace:*", diff --git a/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx b/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx new file mode 100644 index 0000000..5b3ef29 --- /dev/null +++ b/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx @@ -0,0 +1,101 @@ +import { getPublishedArtworkBySlug } from "@cms/db" +import Image from "next/image" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" + +export const dynamic = "force-dynamic" + +type PublicArtworkPageProps = { + params: Promise<{ slug: string }> +} + +function formatLabelList(values: string[]) { + if (values.length === 0) { + return "-" + } + + return values.join(", ") +} + +export default async function PublicArtworkPage({ params }: PublicArtworkPageProps) { + const [{ slug }, t] = await Promise.all([params, getTranslations("Portfolio")]) + const artwork = await getPublishedArtworkBySlug(slug) + + if (!artwork) { + notFound() + } + + return ( +
+
+

{t("badge")}

+

{artwork.title}

+

{artwork.description || t("noDescription")}

+
+ +
+ {artwork.renditions.length === 0 ? ( +
+ {t("noPreview")} +
+ ) : ( + artwork.renditions.map((rendition) => ( +
+ {rendition.mediaAsset.altText +
+ {rendition.slot} + + {rendition.mediaAsset.width ?? "-"} x {rendition.mediaAsset.height ?? "-"} + +
+
+ )) + )} +
+ +
+
+

+ {t("fields.medium")}: {artwork.medium || "-"} +

+

+ {t("fields.dimensions")}: {artwork.dimensions || "-"} +

+

+ {t("fields.year")}: {artwork.year || "-"} +

+

+ {t("fields.availability")}: {artwork.availability || "-"} +

+
+
+

+ {t("fields.galleries")}:{" "} + {formatLabelList(artwork.galleryLinks.map((entry) => entry.gallery.name))} +

+

+ {t("fields.albums")}:{" "} + {formatLabelList(artwork.albumLinks.map((entry) => entry.album.name))} +

+

+ {t("fields.categories")}:{" "} + {formatLabelList(artwork.categoryLinks.map((entry) => entry.category.name))} +

+

+ {t("fields.tags")}:{" "} + {formatLabelList(artwork.tagLinks.map((entry) => entry.tag.name))} +

+
+
+
+ ) +} diff --git a/apps/web/src/app/[locale]/portfolio/page.tsx b/apps/web/src/app/[locale]/portfolio/page.tsx new file mode 100644 index 0000000..1baeffe --- /dev/null +++ b/apps/web/src/app/[locale]/portfolio/page.tsx @@ -0,0 +1,178 @@ +import { listPublishedArtworks, listPublishedPortfolioGroups } from "@cms/db" +import Image from "next/image" +import { getTranslations } from "next-intl/server" + +import { Link } from "@/i18n/navigation" + +export const dynamic = "force-dynamic" + +type SearchParamsInput = Record + +type PortfolioPageProps = { + searchParams: Promise +} + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function resolveGroupFilter(searchParams: SearchParamsInput) { + const gallery = readFirstValue(searchParams.gallery) + if (gallery) { + return { groupType: "gallery" as const, groupSlug: gallery } + } + + const album = readFirstValue(searchParams.album) + if (album) { + return { groupType: "album" as const, groupSlug: album } + } + + const category = readFirstValue(searchParams.category) + if (category) { + return { groupType: "category" as const, groupSlug: category } + } + + const tag = readFirstValue(searchParams.tag) + if (tag) { + return { groupType: "tag" as const, groupSlug: tag } + } + + return null +} + +function findPreviewAsset( + renditions: Array<{ + slot: string + mediaAssetId: string + mediaAsset: { + id: string + altText: string | null + title: string + } + }>, +) { + const byPreference = + renditions.find((item) => item.slot === "card") ?? + renditions.find((item) => item.slot === "thumbnail") ?? + renditions.find((item) => item.slot === "full") ?? + renditions[0] + + return byPreference ?? null +} + +export default async function PortfolioPage({ searchParams }: PortfolioPageProps) { + const [resolvedSearchParams, t] = await Promise.all([searchParams, getTranslations("Portfolio")]) + const activeFilter = resolveGroupFilter(resolvedSearchParams) + + const [groups, artworks] = await Promise.all([ + listPublishedPortfolioGroups(), + listPublishedArtworks( + activeFilter + ? { + groupType: activeFilter.groupType, + groupSlug: activeFilter.groupSlug, + } + : undefined, + ), + ]) + + return ( +
+
+

{t("badge")}

+

{t("title")}

+

{t("description")}

+
+ +
+
+ + {t("filters.clear")} + + + {groups.galleries.map((group) => ( + + {t("filters.gallery")}: {group.name} + + ))} + + {groups.albums.map((group) => ( + + {t("filters.album")}: {group.name} + + ))} + + {groups.categories.map((group) => ( + + {t("filters.category")}: {group.name} + + ))} +
+
+ + {artworks.length === 0 ? ( +
+ {t("empty")} +
+ ) : ( +
+ {artworks.map((artwork) => { + const preview = findPreviewAsset(artwork.renditions) + + return ( +
+ {preview ? ( + {preview.mediaAsset.altText + ) : ( +
+ {t("noPreview")} +
+ )} +
+

{artwork.title}

+

+ {artwork.description || t("noDescription")} +

+ + {t("viewArtwork")} + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/apps/web/src/app/api/media/file/[id]/route.ts b/apps/web/src/app/api/media/file/[id]/route.ts new file mode 100644 index 0000000..93fc3b1 --- /dev/null +++ b/apps/web/src/app/api/media/file/[id]/route.ts @@ -0,0 +1,47 @@ +import { getMediaAssetById } from "@cms/db" + +import { readMediaStorageObject } from "@/lib/media/storage-read" + +export const runtime = "nodejs" + +type RouteContext = { + params: Promise<{ id: string }> +} + +function toBody(data: Uint8Array): BodyInit { + const bytes = new Uint8Array(data.byteLength) + bytes.set(data) + return bytes +} + +export async function GET(_request: Request, context: RouteContext): Promise { + const { id } = await context.params + const asset = await getMediaAssetById(id) + + if (!asset || !asset.storageKey || !asset.isPublished) { + return Response.json( + { + message: "Media file not found", + }, + { status: 404 }, + ) + } + + try { + const data = await readMediaStorageObject(asset.storageKey) + return new Response(toBody(data), { + status: 200, + headers: { + "content-type": asset.mimeType || "application/octet-stream", + "cache-control": "public, max-age=3600", + }, + }) + } catch { + return Response.json( + { + message: "Unable to read media file from configured storage backends", + }, + { status: 404 }, + ) + } +} diff --git a/apps/web/src/lib/media/storage-read.ts b/apps/web/src/lib/media/storage-read.ts new file mode 100644 index 0000000..4a1c09e --- /dev/null +++ b/apps/web/src/lib/media/storage-read.ts @@ -0,0 +1,114 @@ +import { readFile } from "node:fs/promises" +import path from "node:path" +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3" + +export type MediaStorageProvider = "local" | "s3" + +type S3Config = { + bucket: string + region: string + endpoint?: string + accessKeyId: string + secretAccessKey: string + forcePathStyle?: boolean +} + +function parseBoolean(value: string | undefined): boolean { + return value?.toLowerCase() === "true" +} + +export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider { + if (raw?.toLowerCase() === "local") { + return "local" + } + + return "s3" +} + +function resolveS3Config(): S3Config { + const bucket = process.env.CMS_MEDIA_S3_BUCKET?.trim() + const region = process.env.CMS_MEDIA_S3_REGION?.trim() + const accessKeyId = process.env.CMS_MEDIA_S3_ACCESS_KEY_ID?.trim() + const secretAccessKey = process.env.CMS_MEDIA_S3_SECRET_ACCESS_KEY?.trim() + const endpoint = process.env.CMS_MEDIA_S3_ENDPOINT?.trim() || undefined + + if (!bucket || !region || !accessKeyId || !secretAccessKey) { + throw new Error( + "S3 storage selected but required env vars are missing: CMS_MEDIA_S3_BUCKET, CMS_MEDIA_S3_REGION, CMS_MEDIA_S3_ACCESS_KEY_ID, CMS_MEDIA_S3_SECRET_ACCESS_KEY", + ) + } + + return { + bucket, + region, + endpoint, + accessKeyId, + secretAccessKey, + forcePathStyle: parseBoolean(process.env.CMS_MEDIA_S3_FORCE_PATH_STYLE), + } +} + +function createS3Client(config: S3Config): S3Client { + return new S3Client({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle: config.forcePathStyle, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }) +} + +function resolveLocalMediaBaseDirectory(): string { + const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim() + + if (configured) { + return path.resolve(configured) + } + + return path.resolve(process.cwd(), ".data", "media") +} + +async function readFromLocalStorage(storageKey: string): Promise { + const baseDirectory = resolveLocalMediaBaseDirectory() + const outputPath = path.join(baseDirectory, storageKey) + + return readFile(outputPath) +} + +async function readFromS3Storage(storageKey: string): Promise { + const config = resolveS3Config() + const client = createS3Client(config) + + const response = await client.send( + new GetObjectCommand({ + Bucket: config.bucket, + Key: storageKey, + }), + ) + + if (!response.Body) { + throw new Error("S3 object body is empty") + } + + return response.Body.transformToByteArray() +} + +export async function readMediaStorageObject(storageKey: string): Promise { + const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER) + const reads = + preferred === "s3" + ? [() => readFromS3Storage(storageKey), () => readFromLocalStorage(storageKey)] + : [() => readFromLocalStorage(storageKey), () => readFromS3Storage(storageKey)] + + for (const read of reads) { + try { + return await read() + } catch { + // Try next backend. + } + } + + throw new Error("Unable to read media file from configured storage backends") +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 663137a..db12efe 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -60,5 +60,30 @@ "budgetMin": "Budget min.", "budgetMax": "Budget max." } + }, + "Portfolio": { + "badge": "Portfolio", + "title": "Kunstwerk-Portfolio", + "description": "Durchsuche veröffentlichte Kunstwerke aus Galerien, Alben und Kategorien.", + "empty": "Keine Kunstwerke für diesen Filter gefunden.", + "noPreview": "Keine Vorschau verfügbar", + "noDescription": "Keine Beschreibung", + "viewArtwork": "Kunstwerk ansehen", + "filters": { + "clear": "Filter zurücksetzen", + "gallery": "Galerie", + "album": "Album", + "category": "Kategorie" + }, + "fields": { + "medium": "Medium", + "dimensions": "Abmessungen", + "year": "Jahr", + "availability": "Verfügbarkeit", + "galleries": "Galerien", + "albums": "Alben", + "categories": "Kategorien", + "tags": "Tags" + } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 083e1ff..022895e 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -60,5 +60,30 @@ "budgetMin": "Budget min", "budgetMax": "Budget max" } + }, + "Portfolio": { + "badge": "Portfolio", + "title": "Artwork portfolio", + "description": "Browse published artworks from galleries, albums, and categories.", + "empty": "No artworks found for this filter.", + "noPreview": "No preview available", + "noDescription": "No description", + "viewArtwork": "View artwork", + "filters": { + "clear": "Clear filters", + "gallery": "Gallery", + "album": "Album", + "category": "Category" + }, + "fields": { + "medium": "Medium", + "dimensions": "Dimensions", + "year": "Year", + "availability": "Availability", + "galleries": "Galleries", + "albums": "Albums", + "categories": "Categories", + "tags": "Tags" + } } } diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json index 572893d..6c7436c 100644 --- a/apps/web/src/messages/es.json +++ b/apps/web/src/messages/es.json @@ -60,5 +60,30 @@ "budgetMin": "Presupuesto mínimo", "budgetMax": "Presupuesto máximo" } + }, + "Portfolio": { + "badge": "Portafolio", + "title": "Portafolio de obras", + "description": "Explora obras publicadas de galerías, álbumes y categorías.", + "empty": "No se encontraron obras para este filtro.", + "noPreview": "Sin vista previa", + "noDescription": "Sin descripción", + "viewArtwork": "Ver obra", + "filters": { + "clear": "Limpiar filtros", + "gallery": "Galería", + "album": "Álbum", + "category": "Categoría" + }, + "fields": { + "medium": "Técnica", + "dimensions": "Dimensiones", + "year": "Año", + "availability": "Disponibilidad", + "galleries": "Galerías", + "albums": "Álbumes", + "categories": "Categorías", + "tags": "Etiquetas" + } } } diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json index aab02a5..b5314de 100644 --- a/apps/web/src/messages/fr.json +++ b/apps/web/src/messages/fr.json @@ -60,5 +60,30 @@ "budgetMin": "Budget min", "budgetMax": "Budget max" } + }, + "Portfolio": { + "badge": "Portfolio", + "title": "Portfolio d'oeuvres", + "description": "Parcourez les oeuvres publiées par galeries, albums et catégories.", + "empty": "Aucune oeuvre trouvée pour ce filtre.", + "noPreview": "Aperçu indisponible", + "noDescription": "Aucune description", + "viewArtwork": "Voir l'oeuvre", + "filters": { + "clear": "Réinitialiser les filtres", + "gallery": "Galerie", + "album": "Album", + "category": "Catégorie" + }, + "fields": { + "medium": "Médium", + "dimensions": "Dimensions", + "year": "Année", + "availability": "Disponibilité", + "galleries": "Galeries", + "albums": "Albums", + "categories": "Catégories", + "tags": "Tags" + } } } diff --git a/bun.lock b/bun.lock index 11770a8..7ed3fa7 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "name": "@cms/admin", "version": "0.0.1", "dependencies": { - "@aws-sdk/client-s3": "^3.988.0", + "@aws-sdk/client-s3": "3.988.0", "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/i18n": "workspace:*", @@ -58,6 +58,7 @@ "name": "@cms/web", "version": "0.0.1", "dependencies": { + "@aws-sdk/client-s3": "3.988.0", "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/i18n": "workspace:*", diff --git a/docs/product-engineering/request-lifecycle-flows.md b/docs/product-engineering/request-lifecycle-flows.md index 8d2fa40..6c566db 100644 --- a/docs/product-engineering/request-lifecycle-flows.md +++ b/docs/product-engineering/request-lifecycle-flows.md @@ -71,3 +71,17 @@ Key files: - `apps/web/src/app/[locale]/commissions/page.tsx` - `packages/content/src/commissions.ts` - `packages/db/src/commissions.ts` + +## 7. Public Portfolio Rendering + +1. Visitor opens `/{locale}/portfolio` with optional group filter query. +2. Public app loads published portfolio groups and filtered published artworks. +3. Artwork cards render preferred rendition preview (`card` > `thumbnail` > `full`). +4. Image bytes are streamed through web media endpoint using configured storage provider fallback. + +Key files: +- `apps/web/src/app/[locale]/portfolio/page.tsx` +- `apps/web/src/app/[locale]/portfolio/[slug]/page.tsx` +- `apps/web/src/app/api/media/file/[id]/route.ts` +- `apps/web/src/lib/media/storage-read.ts` +- `packages/db/src/media-foundation.ts` diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 92c9c5d..12858a9 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -27,10 +27,13 @@ export { deleteMediaAsset, getMediaAssetById, getMediaFoundationSummary, + getPublishedArtworkBySlug, linkArtworkToGrouping, listArtworks, listMediaAssets, listMediaFoundationGroups, + listPublishedArtworks, + listPublishedPortfolioGroups, updateMediaAsset, } from "./media-foundation" export type { PublicNavigationItem } from "./pages-navigation" diff --git a/packages/db/src/media-foundation.test.ts b/packages/db/src/media-foundation.test.ts index cfaeda2..f00f36c 100644 --- a/packages/db/src/media-foundation.test.ts +++ b/packages/db/src/media-foundation.test.ts @@ -8,11 +8,11 @@ const { mockDb } = vi.hoisted(() => ({ artworkTag: { upsert: vi.fn() }, artworkRendition: { upsert: vi.fn() }, mediaAsset: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn() }, - artwork: { create: vi.fn() }, - gallery: { create: vi.fn() }, - album: { create: vi.fn() }, - category: { create: vi.fn() }, - tag: { create: vi.fn() }, + artwork: { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn() }, + gallery: { create: vi.fn(), findMany: vi.fn() }, + album: { create: vi.fn(), findMany: vi.fn() }, + category: { create: vi.fn(), findMany: vi.fn() }, + tag: { create: vi.fn(), findMany: vi.fn() }, }, })) @@ -26,7 +26,10 @@ import { createMediaAsset, deleteMediaAsset, getMediaAssetById, + getPublishedArtworkBySlug, linkArtworkToGrouping, + listPublishedArtworks, + listPublishedPortfolioGroups, updateMediaAsset, } from "./media-foundation" @@ -42,6 +45,12 @@ describe("media foundation service", () => { if ("findUnique" in value) { value.findUnique.mockReset() } + if ("findMany" in value) { + value.findMany.mockReset() + } + if ("findFirst" in value) { + value.findFirst.mockReset() + } if ("update" in value) { value.update.mockReset() } @@ -120,4 +129,58 @@ describe("media foundation service", () => { expect(mockDb.mediaAsset.update).toHaveBeenCalledTimes(1) expect(mockDb.mediaAsset.delete).toHaveBeenCalledTimes(1) }) + + it("lists published artworks with group filters", async () => { + mockDb.artwork.findMany.mockResolvedValue([]) + + await listPublishedArtworks({ + groupType: "gallery", + groupSlug: "showcase", + }) + + expect(mockDb.artwork.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isPublished: true, + galleryLinks: { + some: { + gallery: { + slug: "showcase", + isVisible: true, + }, + }, + }, + }), + }), + ) + }) + + it("lists published portfolio groups", async () => { + mockDb.gallery.findMany.mockResolvedValue([]) + mockDb.album.findMany.mockResolvedValue([]) + mockDb.category.findMany.mockResolvedValue([]) + mockDb.tag.findMany.mockResolvedValue([]) + + await listPublishedPortfolioGroups() + + expect(mockDb.gallery.findMany).toHaveBeenCalledTimes(1) + expect(mockDb.album.findMany).toHaveBeenCalledTimes(1) + expect(mockDb.category.findMany).toHaveBeenCalledTimes(1) + expect(mockDb.tag.findMany).toHaveBeenCalledTimes(1) + }) + + it("loads a published artwork by slug", async () => { + mockDb.artwork.findFirst.mockResolvedValue(null) + + await getPublishedArtworkBySlug("artwork-slug") + + expect(mockDb.artwork.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + slug: "artwork-slug", + isPublished: true, + }, + }), + ) + }) }) diff --git a/packages/db/src/media-foundation.ts b/packages/db/src/media-foundation.ts index dfa6599..11831bc 100644 --- a/packages/db/src/media-foundation.ts +++ b/packages/db/src/media-foundation.ts @@ -9,6 +9,14 @@ import { import { db } from "./client" +type PublicArtworkGroupType = "gallery" | "album" | "category" | "tag" + +type ListPublishedArtworksInput = { + groupType?: PublicArtworkGroupType + groupSlug?: string + limit?: number +} + export async function listMediaAssets(limit = 24) { return db.mediaAsset.findMany({ orderBy: { updatedAt: "desc" }, @@ -280,3 +288,251 @@ export async function getMediaFoundationSummary() { tags, } } + +export async function listPublishedPortfolioGroups() { + const [galleries, albums, categories, tags] = await Promise.all([ + db.gallery.findMany({ + where: { + isVisible: true, + }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + slug: true, + }, + }), + db.album.findMany({ + where: { + isVisible: true, + }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + slug: true, + }, + }), + db.category.findMany({ + where: { + isVisible: true, + }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + slug: true, + }, + }), + db.tag.findMany({ + orderBy: [{ name: "asc" }], + select: { + id: true, + name: true, + slug: true, + }, + }), + ]) + + return { + galleries, + albums, + categories, + tags, + } +} + +export async function listPublishedArtworks(input: ListPublishedArtworksInput = {}) { + const take = input.limit ?? 36 + const where: Record = { + isPublished: true, + } + + if (input.groupType && input.groupSlug) { + if (input.groupType === "gallery") { + where.galleryLinks = { + some: { + gallery: { + slug: input.groupSlug, + isVisible: true, + }, + }, + } + } else if (input.groupType === "album") { + where.albumLinks = { + some: { + album: { + slug: input.groupSlug, + isVisible: true, + }, + }, + } + } else if (input.groupType === "category") { + where.categoryLinks = { + some: { + category: { + slug: input.groupSlug, + isVisible: true, + }, + }, + } + } else if (input.groupType === "tag") { + where.tagLinks = { + some: { + tag: { + slug: input.groupSlug, + }, + }, + } + } + } + + return db.artwork.findMany({ + where, + orderBy: [{ updatedAt: "desc" }], + take, + include: { + renditions: { + where: { + mediaAsset: { + isPublished: true, + }, + }, + include: { + mediaAsset: { + select: { + id: true, + title: true, + altText: true, + mimeType: true, + width: true, + height: true, + }, + }, + }, + }, + galleryLinks: { + include: { + gallery: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + albumLinks: { + include: { + album: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + categoryLinks: { + include: { + category: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + tagLinks: { + include: { + tag: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + }, + }) +} + +export async function getPublishedArtworkBySlug(slug: string) { + return db.artwork.findFirst({ + where: { + slug, + isPublished: true, + }, + include: { + renditions: { + where: { + mediaAsset: { + isPublished: true, + }, + }, + include: { + mediaAsset: { + select: { + id: true, + title: true, + altText: true, + mimeType: true, + width: true, + height: true, + source: true, + author: true, + copyright: true, + tags: true, + }, + }, + }, + }, + galleryLinks: { + include: { + gallery: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + albumLinks: { + include: { + album: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + categoryLinks: { + include: { + category: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + tagLinks: { + include: { + tag: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }, + }, + }) +}