Add different gallery layouts
This commit is contained in:
164
ToDo2.md
Normal file
164
ToDo2.md
Normal file
@ -0,0 +1,164 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🖥 Frontend App (`app.fellies.art`)
|
||||
|
||||
### Commission Pricing & Display
|
||||
|
||||
- [x] Commission pricing overview page
|
||||
- [x] Display commission types as cards
|
||||
- [x] Show base options (headshot, halfbody, fullbody)
|
||||
- [x] Show extras (backgrounds, NSFW, complexity, etc.)
|
||||
- [x] Use `€` currency and place symbol **after** price
|
||||
- [x] Include example images per type
|
||||
- [x] Collapsible image section (individual per card)
|
||||
- [x] Fix equal-height card issue (use natural height)
|
||||
|
||||
### Commission Ordering Form
|
||||
|
||||
- [x] Build commission ordering form
|
||||
- [x] Use `react-hook-form` and `zod` for validation
|
||||
- [x] Dynamically update form based on selected commission type
|
||||
- [x] Show calculated total price
|
||||
- [x] Support selecting:
|
||||
- [x] Body option (headshot, halfbody, fullbody)
|
||||
- [x] Extras (toggle and range input)
|
||||
- [x] Contact info (email, handle, etc.)
|
||||
- [x] Description / project notes
|
||||
- [x] File upload for reference images
|
||||
- [ ] Preview summary with price (🚧 basic version shown; styled preview TBD)
|
||||
- [x] Dynamically render custom fields per selected commission type
|
||||
|
||||
### TOS / Info
|
||||
|
||||
- [ ] Add Terms of Service (TOS) section
|
||||
- [ ] Styled output from Markdown or HTML
|
||||
- [ ] Editable via admin
|
||||
- [ ] Add “Will draw / Won’t draw” section
|
||||
- [ ] Display below form or in collapsible panel
|
||||
- [ ] Editable via admin
|
||||
|
||||
### UI / Theme
|
||||
|
||||
- [ ] Dark/light theme toggle (`next-themes`)
|
||||
- [ ] Violet-tinted dark theme
|
||||
- [ ] Neutral grey light theme
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Admin App (`admin.fellies.art`)
|
||||
|
||||
### Commission Type Management
|
||||
|
||||
- [x] Manage commission types
|
||||
- [x] Create/edit name, description
|
||||
- [ ] Upload & sort example images
|
||||
- [x] Set sort index (drag and drop)
|
||||
|
||||
- [x] Manage commission options (e.g. headshot)
|
||||
- [x] Set base price per type
|
||||
- [x] Reordering support
|
||||
|
||||
- [x] Manage commission extras (e.g. backgrounds, NSFW)
|
||||
- [x] Support price, percentage, range
|
||||
- [x] Restrict extras to certain types
|
||||
- [x] Reordering support
|
||||
|
||||
- [x] Attach options and extras to commission types
|
||||
- [x] UI for assigning options/extras
|
||||
- [x] Editable price overrides per type
|
||||
|
||||
- [x] Manage custom input fields
|
||||
- [x] Create/select CommissionCustomInput from shared list
|
||||
- [x] Prefill input type and label
|
||||
- [x] Attach to type with overrides (label, required, etc.)
|
||||
- [x] Store in join table correctly
|
||||
- [x] Support sorting
|
||||
|
||||
### TOS / Info Management
|
||||
|
||||
- [ ] TOS editor (Tiptap)
|
||||
- [ ] Markdown + HTML storage
|
||||
- [ ] Preview panel
|
||||
- [ ] “Will draw / Won’t draw” editor
|
||||
- [ ] Markdown or tags input
|
||||
- [ ] Save both sections independently
|
||||
|
||||
### Order Management (future)
|
||||
|
||||
- [ ] Commission order list view
|
||||
- [ ] View all submissions
|
||||
- [ ] Filter by status
|
||||
- [ ] Assign status (pending, accepted, done)
|
||||
- [ ] Contact user
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Shared Logic & Utilities
|
||||
|
||||
### Data & Generation
|
||||
|
||||
- [x] Base mock definitions
|
||||
- [x] `commissionTypes`
|
||||
- [x] `commissionOptions`
|
||||
- [x] `commissionExtras`
|
||||
- [x] Generate `rawCommissions` using base data
|
||||
- [x] Inject price/percentage data
|
||||
- [ ] Add example image mapping per type
|
||||
|
||||
### Types & Validation
|
||||
|
||||
- [x] Define `@types/commissions`
|
||||
- [x] Type for `CommissionType`, `Option`, `Extra`, `CommissionForm`
|
||||
- [x] Create `zod` schemas
|
||||
- [x] Commission form input schema
|
||||
- [x] Type/extras validation
|
||||
- [x] Custom field input schema
|
||||
- [x] Admin-side validation schema
|
||||
|
||||
### Helpers
|
||||
|
||||
- [x] Price calculation utilities
|
||||
- [x] Handle fixed/percent/range extras
|
||||
- [x] Return itemized breakdown
|
||||
- [ ] Order-to-submission transformer
|
||||
- [ ] Format data for admin review
|
||||
- [ ] Utility to generate total price summary
|
||||
|
||||
|
||||
|
||||
|
||||
### Admin Panel
|
||||
- [x] Commission type create/edit form
|
||||
- [ ] Commission option/extra CRUD UI
|
||||
- [ ] View list of commission requests
|
||||
- [ ] View single request details
|
||||
- [ ] Update request status (pending, accepted, rejected, etc.)
|
||||
- [ ] Filter and sort commission requests
|
||||
- [ ] View submitted files / references
|
||||
- [ ] Tag, flag, or star requests for tracking
|
||||
- [ ] Delete / archive requests
|
||||
|
||||
### Backend & Actions
|
||||
- [ ] Submit commission request server action
|
||||
- [ ] Upload reference image(s) action
|
||||
- [ ] Create YCH offer action
|
||||
- [ ] Claim YCH slot action
|
||||
- [ ] Send confirmation email (optional)
|
||||
- [ ] Notify artist via email or admin panel
|
||||
- [ ] Export commission request to JSON or PDF (optional)
|
||||
|
||||
### Extras
|
||||
- [ ] Kanban-style board for request tracking
|
||||
- [ ] Queue page (public or admin)
|
||||
- [x] Markdown/WYSIWYG editor for ToS and descriptions
|
||||
- [ ] Analytics for commission activity
|
||||
- [ ] iCal or Notion export
|
||||
- [ ] Stripe or Ko-fi payment integration
|
@ -4,4 +4,15 @@ const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "placehold.co",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
|
1668
package-lock.json
generated
1668
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@prisma/client": "^6.11.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
@ -27,6 +27,7 @@ model CommissionType {
|
||||
options CommissionTypeOption[]
|
||||
extras CommissionTypeExtra[]
|
||||
customInputs CommissionTypeCustomInput[]
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionOption {
|
||||
@ -40,6 +41,7 @@ model CommissionOption {
|
||||
description String?
|
||||
|
||||
types CommissionTypeOption[]
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionExtra {
|
||||
@ -132,3 +134,151 @@ model TermsOfService {
|
||||
version Int @default(autoincrement())
|
||||
markdown String
|
||||
}
|
||||
|
||||
model CommissionRequest {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
customerName String
|
||||
customerEmail String
|
||||
message String
|
||||
|
||||
optionId String?
|
||||
typeId String?
|
||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||
}
|
||||
|
||||
model PortfolioImage {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
fileKey String @unique
|
||||
originalFile String @unique
|
||||
nsfw Boolean @default(false)
|
||||
published Boolean @default(false)
|
||||
|
||||
altText String?
|
||||
description String?
|
||||
fileType String?
|
||||
name String?
|
||||
slug String?
|
||||
type String?
|
||||
fileSize Int?
|
||||
creationDate DateTime?
|
||||
|
||||
metadata ImageMetadata?
|
||||
|
||||
categories PortfolioCategory[]
|
||||
colors ImageColor[]
|
||||
tags PortfolioTag[]
|
||||
variants ImageVariant[]
|
||||
}
|
||||
|
||||
model PortfolioCategory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String @unique
|
||||
|
||||
slug String?
|
||||
description String?
|
||||
|
||||
images PortfolioImage[]
|
||||
}
|
||||
|
||||
model PortfolioTag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String @unique
|
||||
|
||||
slug String?
|
||||
description String?
|
||||
|
||||
images PortfolioImage[]
|
||||
}
|
||||
|
||||
model Color {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
type String
|
||||
|
||||
hex String?
|
||||
blue Int?
|
||||
green Int?
|
||||
red Int?
|
||||
|
||||
images ImageColor[]
|
||||
}
|
||||
|
||||
model ImageColor {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String
|
||||
colorId String
|
||||
type String
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
color Color @relation(fields: [colorId], references: [id])
|
||||
|
||||
@@unique([imageId, type])
|
||||
}
|
||||
|
||||
model ImageMetadata {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String @unique
|
||||
depth String
|
||||
format String
|
||||
space String
|
||||
channels Int
|
||||
height Int
|
||||
width Int
|
||||
|
||||
autoOrientH Int?
|
||||
autoOrientW Int?
|
||||
bitsPerSample Int?
|
||||
density Int?
|
||||
hasAlpha Boolean?
|
||||
hasProfile Boolean?
|
||||
isPalette Boolean?
|
||||
isProgressive Boolean?
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model ImageVariant {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String
|
||||
s3Key String
|
||||
type String
|
||||
height Int
|
||||
width Int
|
||||
|
||||
fileExtension String?
|
||||
mimeType String?
|
||||
url String?
|
||||
sizeBytes Int?
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
|
||||
@@unique([imageId, type])
|
||||
}
|
||||
|
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