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

164
ToDo2.md Normal file
View 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 / Wont 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 / Wont 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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])
}

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() { 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>
); );
} }

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> <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>

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(), 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"),
}) })

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;
}