Add different gallery layouts

This commit is contained in:
2025-07-12 23:30:52 +02:00
parent 505e1e28b8
commit f0d44ff807
18 changed files with 2449 additions and 4 deletions

View File

@ -0,0 +1,45 @@
"use server";
import prisma from "@/lib/prisma";
export async function getJustifiedImages() {
const images = await prisma.portfolioImage.findMany({
where: {
variants: {
some: { type: "thumbnail" },
},
},
include: {
variants: true,
colors: { include: { color: true } },
},
});
return images
.map((img) => {
const variant = img.variants.find((v) => v.type === "thumbnail");
if (!variant || !variant.width || !variant.height) return null;
const bg = img.colors.find((c) => c.type === "vibrant")?.color.hex ?? "#e5e7eb";
return {
id: img.id,
fileKey: img.fileKey,
altText: img.altText ?? img.name ?? "",
backgroundColor: bg,
width: variant.width,
height: variant.height,
url: variant.url ?? `/api/image/thumbnail/${img.fileKey}.webp`,
};
})
.filter(Boolean) as JustifiedInputImage[];
}
export interface JustifiedInputImage {
id: string;
url: string;
altText: string;
backgroundColor: string;
width: number;
height: number;
}

View File

@ -0,0 +1,33 @@
import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
export async function GET(req: Request, { params }: { params: { key: string[] } }) {
const { key } = await params;
const s3Key = key.join("/");
try {
const command = new GetObjectCommand({
Bucket: "gaertan",
Key: s3Key,
});
const response = await s3.send(command);
if (!response.Body) {
return new Response("No body", { status: 500 });
}
const contentType = response.ContentType ?? "application/octet-stream";
return new Response(response.Body as ReadableStream, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
"Content-Disposition": "inline", // use 'attachment' to force download
},
});
} catch (err) {
console.log(err)
return new Response("Image not found", { status: 404 });
}
}

View File

@ -1,8 +1,91 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Brush,
HeartHandshake,
Palette,
Search,
Star
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
const sections = [
{
title: "Portfolio",
href: "/portfolio",
description: "Browse past artworks and highlights.",
icon: Palette,
},
{
title: "Commissions",
href: "/commissions",
description: "See pricing, types, and open slots.",
icon: Brush,
},
{
title: "YCH (Your Character Here)",
href: "/ych",
description: "Claim open YCHs and other offers or view past ones.",
icon: Star,
},
{
title: "Terms of Service",
href: "/tos",
description: "Read commission rules and conditions.",
icon: HeartHandshake,
},
]
export default function Home() {
return (
<div>
Art app
<div className="flex flex-col gap-6">
{/* Hero Section */}
<div className="relative w-full h-64 md:h-96 overflow-hidden rounded-2xl shadow-md">
<Image
src="https://placehold.co/1600x600.png"
width={1600}
height={600}
alt="Hero"
className="object-cover w-full h-full"
/>
<div className="absolute inset-0 bg-black/40 flex items-center justify-center text-center">
<h1 className="text-white text-3xl md:text-5xl font-bold drop-shadow">
Welcome to PLACEHOLDER
</h1>
</div>
</div>
{/* Search */}
<div className="relative w-full mx-auto">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<Search className="w-4 h-4" />
</span>
<Input
type="text"
placeholder="Search artworks, pages ..."
className="p-6 pl-10 "
/>
</div>
{/* Section Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{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>
);
}

View File

@ -0,0 +1,33 @@
import { ImageCard } from "@/components/portfolio/ImageCard";
import prisma from "@/lib/prisma";
export default async function PortfolioOnePage() {
const images = await prisma.portfolioImage.findMany({
include: {
colors: { include: { color: true } },
variants: true,
}
});
return (
<main className="p-2">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{images.map((image) => {
const backgroundColor =
image.colors.find((c) => c.type === "Muted")?.color?.hex ??
image.colors.find((c) => c.type === "Vibrant")?.color?.hex ??
"#e5e7eb";
return (
<ImageCard
key={image.id}
image={image}
backgroundColor={backgroundColor}
variant="square"
/>
);
})}
</div>
</main>
);
}

View File

@ -0,0 +1,10 @@
import Link from "next/link";
export default function PortfolioPage() {
return (
<div>
<Link href="/portfolio/one">Variant One</Link>
<Link href="/portfolio/two">Variant Two</Link>
</div>
);
}

View File

@ -0,0 +1,12 @@
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
export default async function PortfolioTwoPage() {
const images = await getJustifiedImages();
return (
<main className="p-2 mx-auto max-w-screen-2xl">
<JustifiedGallery images={images} rowHeight={280} />
</main>
);
}

5
src/app/ych/page.tsx Normal file
View File

@ -0,0 +1,5 @@
export default function YchPage() {
return (
<div>YchPage</div>
);
}

View File

@ -12,11 +12,21 @@ export default function TopNav() {
<Link href="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/portfolio">Portfolio</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/commissions">Commissions</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/ych">YCH / Custom offers</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/tos">Terms of Service</Link>

View File

@ -0,0 +1,86 @@
"use client";
import type { Color, ImageColor, ImageVariant, PortfolioImage } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import Image from "next/image";
// ---------- Type Definitions ----------
// Prisma-based image (square layout)
type SquareImage = PortfolioImage & {
variants: ImageVariant[];
colors?: (ImageColor & { color: Color })[];
};
// Flattened image for mosaic layout
type MosaicImage = {
id: string;
url: string;
altText: string;
backgroundColor: string;
};
// Union Props
type ImageCardProps =
| {
variant: "square";
image: SquareImage;
backgroundColor?: string;
}
| {
variant: "mosaic";
image: MosaicImage;
};
export function ImageCard(props: ImageCardProps) {
const { variant } = props;
const isSquare = variant === "square";
const isMosaic = variant === "mosaic";
const backgroundColor =
isSquare
? props.backgroundColor ?? "#e5e7eb"
: props.image.backgroundColor;
const altText =
isSquare
? props.image.altText ?? props.image.name ?? "Image"
: props.image.altText;
const src =
isSquare
? `/api/image/thumbnail/${props.image.fileKey}.webp`
: props.image.url;
return (
<div
className={cn(
"overflow-hidden rounded-md",
isSquare && "aspect-square shadow-sm hover:scale-[1.01] transition-transform",
isMosaic && "w-full h-full"
)}
style={{ backgroundColor }}
>
<div
className={cn(
"relative w-full h-full",
isSquare && "flex items-center justify-center"
)}
>
<Image
src={src}
alt={altText}
fill={isMosaic}
width={isSquare ? 400 : undefined}
height={isSquare ? 400 : undefined}
className={cn(
isSquare && "object-contain max-w-full max-h-full",
isMosaic && "object-cover"
)}
loading="lazy"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import {
JustifiedImage,
justifyPortfolioImages,
} from "@/utils/justifyPortfolioImages";
import { useEffect, useRef, useState } from "react";
import { ImageCard } from "./ImageCard";
interface Props {
images: JustifiedImage[];
rowHeight?: number;
gap?: number;
}
export function JustifiedGallery({ images, rowHeight = 200, gap = 4 }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const [rows, setRows] = useState<JustifiedImage[][]>([]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
const newRows = justifyPortfolioImages(images, containerWidth, rowHeight, gap);
setRows(newRows);
}, [images, containerWidth, rowHeight, gap]);
return (
<div ref={containerRef} className="w-full">
{rows.length === 0 && (
<p className="text-muted-foreground text-center py-20">No images to display</p>
)}
{rows.map((row, i) => (
<div key={i} className="flex gap-[4px] mb-[4px]">
{row.map((img) => (
<div key={img.id} style={{ width: img.width, height: img.height }}>
<ImageCard
image={{
id: img.id,
altText: img.altText,
url: img.url,
backgroundColor: img.backgroundColor,
}}
variant="mosaic"
/>
</div>
))}
</div>
))}
</div>
);
}

21
src/lib/s3.ts Normal file
View File

@ -0,0 +1,21 @@
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const s3 = new S3Client({
region: "us-east-1",
endpoint: "http://10.0.20.11:9010",
forcePathStyle: true,
credentials: {
accessKeyId: "fellies",
secretAccessKey: "XCJ7spqxWZhVn8tkYnfVBFbz2cRKYxPAfeQeIdPRp1",
},
});
export async function getSignedImageUrl(key: string, expiresInSec = 3600) {
const command = new GetObjectCommand({
Bucket: "gaertan",
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: expiresInSec });
}

View File

@ -6,6 +6,6 @@ export const commissionOrderSchema = z.object({
extraIds: z.array(z.string()).optional(),
customFields: z.record(z.string(), z.any()).optional(),
customerName: z.string().min(2, "Enter your name"),
customerEmail: z.string().email("Invalid email"),
customerEmail: z.email("Invalid email"),
message: z.string().min(5, "Please describe what you want"),
})

View File

@ -0,0 +1,53 @@
import type { JustifiedInputImage } from "@/actions/portfolio/getJustifiedImages";
export interface JustifiedImage extends JustifiedInputImage {
width: number;
height: number;
}
export function justifyPortfolioImages(
images: JustifiedInputImage[],
containerWidth: number,
rowHeight: number,
gap: number = 4
): JustifiedImage[][] {
const rows: JustifiedImage[][] = [];
let currentRow: JustifiedInputImage[] = [];
let currentWidth = 0;
for (const image of images) {
const scale = rowHeight / image.height;
const scaledWidth = image.width * scale;
if (currentWidth + scaledWidth + gap * currentRow.length > containerWidth && currentRow.length > 0) {
const totalAspectRatio = currentRow.reduce((sum, img) => sum + img.width / img.height, 0);
const adjustedRow: JustifiedImage[] = currentRow.map((img) => {
const ratio = img.width / img.height;
const width = (containerWidth - gap * (currentRow.length - 1)) * (ratio / totalAspectRatio);
return { ...img, width, height: rowHeight };
});
rows.push(adjustedRow);
currentRow = [];
currentWidth = 0;
}
currentRow.push(image);
currentWidth += scaledWidth;
}
if (currentRow.length > 0) {
const adjustedRow: JustifiedImage[] = currentRow.map((img) => {
const scale = rowHeight / img.height;
return {
...img,
width: img.width * scale,
height: rowHeight,
};
});
rows.push(adjustedRow);
}
return rows;
}