Changed a lot of things

This commit is contained in:
2025-07-27 12:26:00 +02:00
parent 4260a990e8
commit 4d6ef3c832
15 changed files with 671 additions and 192 deletions

View File

@ -14,6 +14,193 @@ datasource db {
url = env("DATABASE_URL")
}
// Portfolio
model PortfolioImage {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
fileKey String @unique
originalFile String @unique
fileType String
name String
fileSize Int
needsWork Boolean @default(true)
nsfw Boolean @default(false)
published Boolean @default(false)
setAsHeader Boolean @default(false)
altText String?
description String?
month Int?
year Int?
creationDate DateTime?
albumId String?
typeId String?
album PortfolioAlbum? @relation(fields: [albumId], references: [id])
type PortfolioType? @relation(fields: [typeId], references: [id])
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
sortContexts PortfolioSortContext[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioAlbum {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
description String?
images PortfolioImage[]
}
model PortfolioType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
description String?
images PortfolioImage[]
}
model PortfolioCategory {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
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 @unique
description String?
images PortfolioImage[]
}
model PortfolioSortContext {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
year String
albumId String
type String
group String
sortOrder Int
imageId String
image PortfolioImage @relation(fields: [imageId], references: [id])
@@unique([imageId, year, albumId, type, group])
}
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])
}
model CommissionType {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -149,137 +336,3 @@ model CommissionRequest {
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)
setAsHeader 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

@ -1,40 +1,53 @@
"use server";
// "use server";
// import prisma from "@/lib/prisma";
// export async function getJustifiedImages() {
// const images = await prisma.portfolioImage.findMany({
// where: {
// variants: {
// some: { type: "resized" },
// },
// },
// include: {
// variants: true,
// colors: { include: { color: true } },
// },
// });
// return images
// .map((img) => {
// const variant = img.variants.find((v) => v.type === "resized");
// 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/resized/${img.fileKey}.webp`,
// };
// })
// .filter(Boolean) as JustifiedInputImage[];
// }
// export interface JustifiedInputImage {
// id: string;
// url: string;
// altText: string;
// backgroundColor: string;
// width: number;
// height: number;
// }
"use server"
import prisma from "@/lib/prisma";
export async function getJustifiedImages() {
const images = await prisma.portfolioImage.findMany({
where: {
variants: {
some: { type: "resized" },
},
},
include: {
variants: true,
colors: { include: { color: true } },
},
});
return images
.map((img) => {
const variant = img.variants.find((v) => v.type === "resized");
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/resized/${img.fileKey}.webp`,
};
})
.filter(Boolean) as JustifiedInputImage[];
}
export interface JustifiedInputImage {
id: string;
url: string;
@ -42,4 +55,165 @@ export interface JustifiedInputImage {
backgroundColor: string;
width: number;
height: number;
}
fileKey: string;
}
interface Variant {
type: string;
url?: string | null;
width: number;
height: number;
}
interface Color {
type: string;
color: {
hex: string | null;
};
}
interface PortfolioImage {
id: string;
fileKey: string;
name?: string | null;
altText?: string | null;
variants: Variant[];
colors: Color[];
}
interface PortfolioImageSortContext {
image: PortfolioImage;
group: "highlighted" | "featured" | "default" | string;
sortOrder: number;
}
// const groupPriority = {
// highlighted: 0,
// featured: 1,
// default: 2,
// } as const;
function shuffleImages<T>(arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// Stub: Replace this with your actual DB or API call
async function getImageSortContext(params: {
year?: string;
albumId?: string;
type: string;
}): Promise<PortfolioImageSortContext[]> {
const { year, albumId, type } = params;
const typeEntry = await prisma.portfolioType.findUnique({
where: { slug: type },
// select: { id: true },
});
if (!typeEntry) return [];
const typeId = typeEntry.id;
const sortContexts = await prisma.portfolioSortContext.findMany({
where: {
type: typeId,
...(year ? { year: year } : {}),
...(albumId ? { albumId } : {}),
image: {
// published: true,
},
},
include: {
image: {
include: {
variants: true,
colors: {
include: {
color: true,
},
},
},
},
},
});
return sortContexts;
}
export async function getJustifiedImages(
year: string | null | undefined,
albumId: string | null | undefined,
type: string,
shouldShuffle = false
): Promise<JustifiedInputImage[] | null> {
if (!year && !albumId) return null;
const sortContexts = await getImageSortContext({
year: year || undefined,
albumId: albumId || undefined,
type,
});
// const validImages = sortContexts
// .filter((ctx) => ctx.image && ctx.image.variants.some((v) => v.type === "resized"))
// .sort((a, b) => {
// const groupA = groupPriority[a.group as keyof typeof groupPriority] ?? 3;
// const groupB = groupPriority[b.group as keyof typeof groupPriority] ?? 3;
// if (groupA !== groupB) return groupA - groupB;
// return a.sortOrder - b.sortOrder;
// });
type GroupName = "highlighted" | "featured" | "default";
const grouped: Record<GroupName, PortfolioImageSortContext[]> = {
highlighted: [],
featured: [],
default: [],
};
for (const ctx of sortContexts) {
const rawGroup = ctx.group?.toLowerCase() ?? "default";
const group = ["highlighted", "featured", "default"].includes(rawGroup)
? (rawGroup as GroupName)
: "default";
grouped[group].push(ctx);
}
const processGroup = (list: PortfolioImageSortContext[]) =>
shouldShuffle
? shuffleImages(list)
: [...list].sort((a, b) => a.sortOrder - b.sortOrder);
const finalList = [
...processGroup(grouped.highlighted),
...processGroup(grouped.featured),
...processGroup(grouped.default),
];
const output: JustifiedInputImage[] = [];
for (const ctx of finalList) {
const img = ctx.image;
const variant = img.variants.find((v) => v.type === "resized");
if (!variant || !variant.width || !variant.height) continue;
const bg = img.colors.find((c) => c.type === "Vibrant")?.color.hex ?? "#e5e7eb";
output.push({
id: img.id,
fileKey: img.fileKey,
altText: img.altText ?? img.name ?? "",
backgroundColor: bg,
width: variant.width,
height: variant.height,
url: variant.url ?? `/api/image/resized/${img.fileKey}.webp`,
});
}
return output;
}

View File

@ -0,0 +1,42 @@
"use server"
import prisma from "@/lib/prisma";
export async function getPortfolioFilters() {
const albums = await prisma.portfolioAlbum.findMany({
where: {
// images: {
// some: {
// published: true,
// },
// },
},
select: {
id: true,
name: true,
},
orderBy: {
sortIndex: "asc",
},
});
const yearsRaw = await prisma.portfolioImage.findMany({
where: {
// published: true,
year: {
not: null,
},
},
distinct: ["year"],
select: {
year: true,
},
});
const years = yearsRaw
.map((y) => y.year!)
.sort((a, b) => b - a)
.map(String);
return { albums, years };
}

View File

@ -42,13 +42,13 @@ export default function RootLayout({
<div>
<Banner />
</div>
<header className="sticky top-0 z-50 h-14 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-2">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Header />
</header>
<main className="container mx-auto px-4 py-8">
<main className="container mx-auto">
{children}
</main>
<footer className="mt-auto px-4 py-2 h-14 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 ">
<footer className="mt-auto p-4 h-14 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 ">
<Footer />
</footer>
<Toaster />

View File

@ -45,7 +45,7 @@ const sections = [
export default function Home() {
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-10 px-4 py-8">
{/* Hero Section */}
<div className="text-center flex flex-col items-center justify-center mt-10 px-4">
<h1 className="text-4xl md:text-5xl font-bold mb-4">

View File

@ -1,8 +1,11 @@
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
export default async function PortfolioTwoPage() {
const images = await getJustifiedImages();
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
const { year, album } = await searchParams;
const images = await getJustifiedImages(year, album, "art", false);
if (!images) return null
return (
<main className="p-2 mx-auto max-w-screen-2xl">

View File

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

View File

@ -0,0 +1,16 @@
import PortfolioSubNav from "@/components/portfolio/PortfolioSubNav"
export default function PortfolioLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<PortfolioSubNav />
<div className="px-4 py-8">
{children}
</div>
</div>
)
}

View File

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

View File

@ -1,10 +0,0 @@
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

@ -4,11 +4,9 @@ import TopNav from "./TopNav";
export default function Header() {
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<div className="w-full">
<div className="flex items-center justify-between px-4 md:px-8 py-2">
<TopNav />
</div>
<div>
<ModeToggle />
</div>
</div>

View File

@ -9,8 +9,9 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "../u
const links = [
{ href: "/", label: "Home" },
{ href: "/portfolio", label: "Art Portfolio" },
{ href: "/miniatures", label: "Miniatures" },
{ href: "/portfolio/art", label: "Artworks" },
{ href: "/portfolio/artfight", label: "Artfight" },
{ href: "/portfolio/minis", label: "Miniatures" },
{ href: "/commissions", label: "Commissions" },
{ href: "/ych", label: "YCH / Custom offers" },
{ href: "/tos", label: "Terms of Service" },

View File

@ -54,7 +54,7 @@ export function ImageCard(props: ImageCardProps) {
? `/api/image/thumbnail/${props.image.fileKey}.webp`
: props.image.url;
console.log(props.image);
// console.log(props.image);
return (
<Link href={`/portfolio/${props.image.id}`}>

View File

@ -0,0 +1,192 @@
"use client";
import { getPortfolioFilters } from "@/actions/portfolio/getPortfolioFilters";
import { Button } from "@/components/ui/button";
import clsx from "clsx";
import { CalendarIcon, ImagesIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
interface Album {
id: string;
name: string;
}
export default function PortfolioSubNav({
onFilterSelect,
}: {
onFilterSelect?: (value: string) => void;
}) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState<"year" | "album">("year");
const [selectedYear, setSelectedYear] = useState<string | null>(null);
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
const [years, setYears] = useState<string[]>([]);
const [albums, setAlbums] = useState<Album[]>([]);
// useEffect(() => {
// const year = searchParams.get("year");
// const album = searchParams.get("album");
// if (year) {
// setMode("year");
// setSelectedYear(year);
// } else if (album) {
// setMode("album");
// setSelectedAlbum(album);
// }
// }, [searchParams]);
useEffect(() => {
const fetchFilters = async () => {
const { years, albums } = await getPortfolioFilters();
setYears(years);
setAlbums(albums);
const urlYear = searchParams.get("year");
const urlAlbum = searchParams.get("album");
if (urlYear) {
setMode("year");
setSelectedYear(urlYear);
onFilterSelect?.(urlYear);
} else if (urlAlbum) {
setMode("album");
setSelectedAlbum(urlAlbum);
onFilterSelect?.(urlAlbum);
} else if (years.length > 0) {
const defaultYear = years[0];
setMode("year");
setSelectedYear(defaultYear);
const params = new URLSearchParams(searchParams.toString());
params.set("year", defaultYear);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
onFilterSelect?.(defaultYear);
}
};
fetchFilters();
}, [onFilterSelect, pathname, router, searchParams]);
const handleSelect = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (mode === "year") {
setSelectedYear(value);
setSelectedAlbum(null);
params.set("year", value);
params.delete("album");
} else {
setSelectedAlbum(value);
setSelectedYear(null);
params.set("album", value);
params.delete("year");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
onFilterSelect?.(value);
};
const activeList =
mode === "year"
? years.map((y) => ({ id: y, name: y }))
: albums.map((a) => ({ id: a.id, name: a.name }));
const selected = mode === "year" ? selectedYear : selectedAlbum;
return (
<div className="w-full">
<div className="flex items-center gap-4 px-4 md:px-8 py-2 overflow-x-auto">
{/* Toggle icons */}
<div className="flex items-center gap-1 shrink-0">
<ModeButton
icon={<CalendarIcon className="w-4 h-4" />}
isActive={mode === "year"}
onClick={() => setMode("year")}
label="Filter by year"
/>
<ModeButton
icon={<ImagesIcon className="w-4 h-4" />}
isActive={mode === "album"}
onClick={() => setMode("album")}
label="Filter by album"
/>
</div>
{/* Divider */}
<div className="h-5 w-px bg-border" />
{/* Filter buttons */}
<div className="flex items-center gap-1 flex-wrap">
{activeList.map((item) => (
<FilterButton
key={item.id}
label={item.name}
isActive={selected === item.id}
onClick={() => handleSelect(item.id)}
/>
))}
</div>
</div>
</div>
);
}
function ModeButton({
icon,
isActive,
onClick,
label,
}: {
icon: React.ReactNode;
isActive: boolean;
onClick: () => void;
label: string;
}) {
return (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={clsx(
"transition-colors",
isActive ? "bg-secondary text-secondary-foreground" : "hover:bg-muted"
)}
>
{icon}
</Button>
);
}
function FilterButton({
label,
isActive,
onClick,
}: {
label: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClick}
className={clsx(
"text-sm px-3 py-1 transition-colors",
isActive
? "bg-secondary text-secondary-foreground"
: "hover:bg-muted text-muted-foreground"
)}
>
{label}
</Button>
);
}