Add new gallery variant
This commit is contained in:
130
src/actions/artworks/generateGalleryVariant.ts
Normal file
130
src/actions/artworks/generateGalleryVariant.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { s3 } from "@/lib/s3";
|
||||||
|
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const GALLERY_TARGET_SIZE = 300;
|
||||||
|
|
||||||
|
export async function generateGalleryVariant(
|
||||||
|
artworkId: string,
|
||||||
|
opts?: { force?: boolean },
|
||||||
|
) {
|
||||||
|
const artwork = await prisma.artwork.findUnique({
|
||||||
|
where: { id: artworkId },
|
||||||
|
include: { file: true, variants: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!artwork || !artwork.file) {
|
||||||
|
throw new Error("Artwork or file not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = artwork.variants.find((v) => v.type === "gallery");
|
||||||
|
if (existing && !opts?.force) {
|
||||||
|
return { ok: true, skipped: true, variantId: existing.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const source =
|
||||||
|
artwork.variants.find((v) => v.type === "modified") ??
|
||||||
|
artwork.variants.find((v) => v.type === "original");
|
||||||
|
|
||||||
|
if (!source?.s3Key) {
|
||||||
|
throw new Error("Missing source variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await getImageBufferFromS3Key(source.s3Key);
|
||||||
|
const srcMeta = await sharp(buffer).metadata();
|
||||||
|
|
||||||
|
const { width, height } = srcMeta;
|
||||||
|
let resizeOptions: { width?: number; height?: number };
|
||||||
|
if (width && height) {
|
||||||
|
resizeOptions =
|
||||||
|
height < width
|
||||||
|
? { height: GALLERY_TARGET_SIZE }
|
||||||
|
: { width: GALLERY_TARGET_SIZE };
|
||||||
|
} else {
|
||||||
|
resizeOptions = { height: GALLERY_TARGET_SIZE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryBuffer = await sharp(buffer)
|
||||||
|
.resize({ ...resizeOptions, withoutEnlargement: true })
|
||||||
|
.toFormat("webp")
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const galleryMetadata = await sharp(galleryBuffer).metadata();
|
||||||
|
const galleryKey = `gallery/${artwork.file.fileKey}.webp`;
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: `${process.env.BUCKET_NAME}`,
|
||||||
|
Key: galleryKey,
|
||||||
|
Body: galleryBuffer,
|
||||||
|
ContentType: "image/" + galleryMetadata.format,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const variant = await prisma.fileVariant.upsert({
|
||||||
|
where: { artworkId_type: { artworkId: artwork.id, type: "gallery" } },
|
||||||
|
create: {
|
||||||
|
s3Key: galleryKey,
|
||||||
|
type: "gallery",
|
||||||
|
height: galleryMetadata.height ?? 0,
|
||||||
|
width: galleryMetadata.width ?? 0,
|
||||||
|
fileExtension: galleryMetadata.format,
|
||||||
|
mimeType: "image/" + galleryMetadata.format,
|
||||||
|
sizeBytes: galleryMetadata.size,
|
||||||
|
artworkId: artwork.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
s3Key: galleryKey,
|
||||||
|
height: galleryMetadata.height ?? 0,
|
||||||
|
width: galleryMetadata.width ?? 0,
|
||||||
|
fileExtension: galleryMetadata.format,
|
||||||
|
mimeType: "image/" + galleryMetadata.format,
|
||||||
|
sizeBytes: galleryMetadata.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, skipped: false, variantId: variant.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateGalleryVariantsMissing(args?: {
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const limit = Math.min(Math.max(args?.limit ?? 20, 1), 100);
|
||||||
|
|
||||||
|
const artworks = await prisma.artwork.findMany({
|
||||||
|
where: { variants: { none: { type: "gallery" } } },
|
||||||
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||||
|
take: limit,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: Array<{ artworkId: string; ok: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
for (const a of artworks) {
|
||||||
|
try {
|
||||||
|
await generateGalleryVariant(a.id);
|
||||||
|
results.push({ artworkId: a.id, ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
artworkId: a.id,
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : "Failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = results.filter((r) => r.ok).length;
|
||||||
|
const failed = results.length - ok;
|
||||||
|
|
||||||
|
return {
|
||||||
|
picked: artworks.length,
|
||||||
|
processed: results.length,
|
||||||
|
ok,
|
||||||
|
failed,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
24
src/actions/artworks/getGalleryVariantStats.ts
Normal file
24
src/actions/artworks/getGalleryVariantStats.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export type GalleryVariantStats = {
|
||||||
|
total: number;
|
||||||
|
withGallery: number;
|
||||||
|
missing: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGalleryVariantStats(): Promise<GalleryVariantStats> {
|
||||||
|
const [total, withGallery] = await Promise.all([
|
||||||
|
prisma.artwork.count(),
|
||||||
|
prisma.artwork.count({
|
||||||
|
where: { variants: { some: { type: "gallery" } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
withGallery,
|
||||||
|
missing: total - withGallery,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,7 +8,10 @@ import sharp from "sharp";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
|
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
|
||||||
|
|
||||||
export async function createImageFromFile(imageFile: File, opts?: { originalName?: string, colorMode?: "inline" | "defer" | "off" }) {
|
export async function createImageFromFile(
|
||||||
|
imageFile: File,
|
||||||
|
opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" },
|
||||||
|
) {
|
||||||
if (!(imageFile instanceof File)) {
|
if (!(imageFile instanceof File)) {
|
||||||
console.log("No image or invalid type");
|
console.log("No image or invalid type");
|
||||||
return null;
|
return null;
|
||||||
@ -29,6 +32,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
const modifiedKey = `modified/${fileKey}.webp`;
|
const modifiedKey = `modified/${fileKey}.webp`;
|
||||||
const resizedKey = `resized/${fileKey}.webp`;
|
const resizedKey = `resized/${fileKey}.webp`;
|
||||||
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
||||||
|
const galleryKey = `gallery/${fileKey}.webp`;
|
||||||
|
|
||||||
const sharpData = sharp(buffer);
|
const sharpData = sharp(buffer);
|
||||||
const metadata = await sharpData.metadata();
|
const metadata = await sharpData.metadata();
|
||||||
@ -40,7 +44,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: originalKey,
|
Key: originalKey,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ContentType: "image/" + metadata.format,
|
ContentType: "image/" + metadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Modified file
|
//--- Modified file
|
||||||
@ -53,7 +57,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: modifiedKey,
|
Key: modifiedKey,
|
||||||
Body: modifiedBuffer,
|
Body: modifiedBuffer,
|
||||||
ContentType: "image/" + modifiedMetadata.format,
|
ContentType: "image/" + modifiedMetadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Resized file
|
//--- Resized file
|
||||||
@ -62,7 +66,8 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
|
|
||||||
let resizeOptions: { width?: number; height?: number };
|
let resizeOptions: { width?: number; height?: number };
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
resizeOptions = height < width ? { height: targetSize } : { width: targetSize };
|
resizeOptions =
|
||||||
|
height < width ? { height: targetSize } : { width: targetSize };
|
||||||
} else {
|
} else {
|
||||||
resizeOptions = { height: targetSize };
|
resizeOptions = { height: targetSize };
|
||||||
}
|
}
|
||||||
@ -80,7 +85,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: resizedKey,
|
Key: resizedKey,
|
||||||
Body: resizedBuffer,
|
Body: resizedBuffer,
|
||||||
ContentType: "image/" + resizedMetadata.format,
|
ContentType: "image/" + resizedMetadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Thumbnail file
|
//--- Thumbnail file
|
||||||
@ -88,7 +93,10 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
|
|
||||||
let thumbnailOptions: { width?: number; height?: number };
|
let thumbnailOptions: { width?: number; height?: number };
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
thumbnailOptions = height < width ? { height: thumbnailTargetSize } : { width: thumbnailTargetSize };
|
thumbnailOptions =
|
||||||
|
height < width
|
||||||
|
? { height: thumbnailTargetSize }
|
||||||
|
: { width: thumbnailTargetSize };
|
||||||
} else {
|
} else {
|
||||||
thumbnailOptions = { height: thumbnailTargetSize };
|
thumbnailOptions = { height: thumbnailTargetSize };
|
||||||
}
|
}
|
||||||
@ -106,7 +114,36 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: thumbnailKey,
|
Key: thumbnailKey,
|
||||||
Body: thumbnailBuffer,
|
Body: thumbnailBuffer,
|
||||||
ContentType: "image/" + thumbnailMetadata.format,
|
ContentType: "image/" + thumbnailMetadata.format,
|
||||||
})
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
//--- Gallery file
|
||||||
|
const galleryTargetSize = 300;
|
||||||
|
|
||||||
|
let galleryOptions: { width?: number; height?: number };
|
||||||
|
if (width && height) {
|
||||||
|
galleryOptions =
|
||||||
|
height < width
|
||||||
|
? { height: galleryTargetSize }
|
||||||
|
: { width: galleryTargetSize };
|
||||||
|
} else {
|
||||||
|
galleryOptions = { height: galleryTargetSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryBuffer = await sharp(modifiedBuffer)
|
||||||
|
.resize({ ...galleryOptions, withoutEnlargement: true })
|
||||||
|
.toFormat("webp")
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const galleryMetadata = await sharp(galleryBuffer).metadata();
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: `${process.env.BUCKET_NAME}`,
|
||||||
|
Key: galleryKey,
|
||||||
|
Body: galleryBuffer,
|
||||||
|
ContentType: "image/" + galleryMetadata.format,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileRecord = await prisma.fileData.create({
|
const fileRecord = await prisma.fileData.create({
|
||||||
@ -193,6 +230,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
sizeBytes: thumbnailMetadata.size,
|
sizeBytes: thumbnailMetadata.size,
|
||||||
artworkId: artworkRecord.id,
|
artworkId: artworkRecord.id,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
s3Key: galleryKey,
|
||||||
|
type: "gallery",
|
||||||
|
height: galleryMetadata.height ?? 0,
|
||||||
|
width: galleryMetadata.width ?? 0,
|
||||||
|
fileExtension: galleryMetadata.format,
|
||||||
|
mimeType: "image/" + galleryMetadata.format,
|
||||||
|
sizeBytes: galleryMetadata.size,
|
||||||
|
artworkId: artworkRecord.id,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -206,6 +253,5 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
// (nothing else to do here)
|
// (nothing else to do here)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return artworkRecord;
|
return artworkRecord;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
|
|||||||
{item && <DeleteArtworkButton artworkId={item.id} />}
|
{item && <DeleteArtworkButton artworkId={item.id} />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{item && <ArtworkVariants variants={item.variants} />}
|
{item && <ArtworkVariants artworkId={item.id} variants={item.variants} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -45,4 +45,4 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
|
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
|
||||||
|
import { ArtworkGalleryVariantProcessor } from "@/components/artworks/ArtworkGalleryVariantProcessor";
|
||||||
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
||||||
import { getArtworksPage } from "@/lib/queryArtworks";
|
import { getArtworksPage } from "@/lib/queryArtworks";
|
||||||
|
|
||||||
@ -59,6 +60,7 @@ export default async function ArtworksPage({
|
|||||||
<h1 className="text-2xl font-bold">Artworks</h1>
|
<h1 className="text-2xl font-bold">Artworks</h1>
|
||||||
{/* <ProcessArtworkColorsButton /> */}
|
{/* <ProcessArtworkColorsButton /> */}
|
||||||
<ArtworkColorProcessor />
|
<ArtworkColorProcessor />
|
||||||
|
<ArtworkGalleryVariantProcessor />
|
||||||
<ArtworksTable />
|
<ArtworksTable />
|
||||||
</div>
|
</div>
|
||||||
// <div>
|
// <div>
|
||||||
@ -89,4 +91,4 @@ export default async function ArtworksPage({
|
|||||||
// </div>
|
// </div>
|
||||||
// </div >
|
// </div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/components/artworks/ArtworkGalleryVariantProcessor.tsx
Normal file
62
src/components/artworks/ArtworkGalleryVariantProcessor.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { generateGalleryVariantsMissing } from "@/actions/artworks/generateGalleryVariant";
|
||||||
|
import { getGalleryVariantStats } from "@/actions/artworks/getGalleryVariantStats";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function ArtworkGalleryVariantProcessor() {
|
||||||
|
const [stats, setStats] = React.useState<Awaited<
|
||||||
|
ReturnType<typeof getGalleryVariantStats>
|
||||||
|
> | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [msg, setMsg] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshStats = React.useCallback(async () => {
|
||||||
|
const s = await getGalleryVariantStats();
|
||||||
|
setStats(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void refreshStats();
|
||||||
|
}, [refreshStats]);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await generateGalleryVariantsMissing({ limit: 50 });
|
||||||
|
setMsg(`Processed ${res.processed}: ${res.ok} ok, ${res.failed} failed`);
|
||||||
|
await refreshStats();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const done = !!stats && stats.missing === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button onClick={run} disabled={loading || done}>
|
||||||
|
{done
|
||||||
|
? "All gallery variants present"
|
||||||
|
: loading
|
||||||
|
? "Generating…"
|
||||||
|
: "Generate missing gallery variants"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Ready {stats.withGallery}/{stats.total}
|
||||||
|
{stats.missing > 0 && ` · Missing ${stats.missing}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <p className="text-sm text-muted-foreground">{msg}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,19 @@
|
|||||||
import { FileVariant } from "@/generated/prisma/client";
|
"use client";
|
||||||
|
|
||||||
|
import { generateGalleryVariant } from "@/actions/artworks/generateGalleryVariant";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { FileVariant } from "@/generated/prisma/client";
|
||||||
import { formatFileSize } from "@/utils/formatFileSize";
|
import { formatFileSize } from "@/utils/formatFileSize";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
|
||||||
const ORDER: Record<string, number> = {
|
const ORDER: Record<string, number> = {
|
||||||
thumbnail: 0,
|
thumbnail: 0,
|
||||||
resized: 1,
|
gallery: 1,
|
||||||
modified: 2,
|
resized: 2,
|
||||||
original: 3,
|
modified: 3,
|
||||||
|
original: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
function byVariantOrder(a: FileVariant, b: FileVariant) {
|
function byVariantOrder(a: FileVariant, b: FileVariant) {
|
||||||
@ -16,22 +23,58 @@ function byVariantOrder(a: FileVariant, b: FileVariant) {
|
|||||||
return a.type.localeCompare(b.type);
|
return a.type.localeCompare(b.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtworkVariants({ variants }: { variants: FileVariant[] }) {
|
export default function ArtworkVariants({
|
||||||
|
artworkId,
|
||||||
|
variants,
|
||||||
|
}: {
|
||||||
|
artworkId: string;
|
||||||
|
variants: FileVariant[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const hasGallery = variants.some((v) => v.type === "gallery");
|
||||||
const sorted = [...variants].sort(byVariantOrder);
|
const sorted = [...variants].sort(byVariantOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="font-semibold text-lg mb-2">Variants</h2>
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<h2 className="font-semibold text-lg">Variants</h2>
|
||||||
|
{!hasGallery ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
await generateGalleryVariant(artworkId);
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? "Generating..." : "Generate gallery"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{sorted.map((variant) => (
|
{sorted.map((variant) => (
|
||||||
<div key={variant.id}>
|
<div key={variant.id}>
|
||||||
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
|
<div className="text-sm mb-1">
|
||||||
|
{variant.type} | {variant.width}x{variant.height}px |{" "}
|
||||||
|
{variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}
|
||||||
|
</div>
|
||||||
{variant.s3Key && (
|
{variant.s3Key && (
|
||||||
<NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" />
|
<NextImage
|
||||||
|
src={`/api/image/${variant.s3Key}`}
|
||||||
|
alt={variant.s3Key}
|
||||||
|
width={variant.width}
|
||||||
|
height={variant.height}
|
||||||
|
className="rounded shadow max-w-md"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user