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 */
|
/* config options here */
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "placehold.co",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default nextConfig;
|
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"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.844.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@prisma/client": "^6.11.1",
|
"@prisma/client": "^6.11.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
@ -27,6 +27,7 @@ model CommissionType {
|
|||||||
options CommissionTypeOption[]
|
options CommissionTypeOption[]
|
||||||
extras CommissionTypeExtra[]
|
extras CommissionTypeExtra[]
|
||||||
customInputs CommissionTypeCustomInput[]
|
customInputs CommissionTypeCustomInput[]
|
||||||
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionOption {
|
model CommissionOption {
|
||||||
@ -39,7 +40,8 @@ model CommissionOption {
|
|||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
types CommissionTypeOption[]
|
types CommissionTypeOption[]
|
||||||
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionExtra {
|
model CommissionExtra {
|
||||||
@ -132,3 +134,151 @@ model TermsOfService {
|
|||||||
version Int @default(autoincrement())
|
version Int @default(autoincrement())
|
||||||
markdown String
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col gap-6">
|
||||||
Art app
|
{/* 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>
|
</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>
|
<Link href="/">Home</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
|
<Link href="/portfolio">Portfolio</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link href="/commissions">Commissions</Link>
|
<Link href="/commissions">Commissions</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
|
<Link href="/ych">YCH / Custom offers</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link href="/tos">Terms of Service</Link>
|
<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(),
|
extraIds: z.array(z.string()).optional(),
|
||||||
customFields: z.record(z.string(), z.any()).optional(),
|
customFields: z.record(z.string(), z.any()).optional(),
|
||||||
customerName: z.string().min(2, "Enter your name"),
|
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"),
|
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