Add different gallery layouts
This commit is contained in:
45
src/actions/portfolio/getJustifiedImages.ts
Normal file
45
src/actions/portfolio/getJustifiedImages.ts
Normal 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;
|
||||
}
|
33
src/app/api/image/[...key]/route.ts
Normal file
33
src/app/api/image/[...key]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
33
src/app/portfolio/one/page.tsx
Normal file
33
src/app/portfolio/one/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
src/app/portfolio/page.tsx
Normal file
10
src/app/portfolio/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
src/app/portfolio/two/page.tsx
Normal file
12
src/app/portfolio/two/page.tsx
Normal 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
5
src/app/ych/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
export default function YchPage() {
|
||||
return (
|
||||
<div>YchPage</div>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
86
src/components/portfolio/ImageCard.tsx
Normal file
86
src/components/portfolio/ImageCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/portfolio/JustifiedGallery.tsx
Normal file
59
src/components/portfolio/JustifiedGallery.tsx
Normal 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
21
src/lib/s3.ts
Normal 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 });
|
||||
}
|
@ -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"),
|
||||
})
|
||||
|
53
src/utils/justifyPortfolioImages.ts
Normal file
53
src/utils/justifyPortfolioImages.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user