Changes to image sort
This commit is contained in:
		
							
								
								
									
										2906
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2906
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -34,7 +34,11 @@ model PortfolioImage {
 | 
				
			|||||||
  altText      String?
 | 
					  altText      String?
 | 
				
			||||||
  description  String?
 | 
					  description  String?
 | 
				
			||||||
  month        Int?
 | 
					  month        Int?
 | 
				
			||||||
 | 
					  sortKey      Int?
 | 
				
			||||||
  year         Int?
 | 
					  year         Int?
 | 
				
			||||||
 | 
					  okLabL       Float?
 | 
				
			||||||
 | 
					  okLabA       Float?
 | 
				
			||||||
 | 
					  okLabB       Float?
 | 
				
			||||||
  creationDate DateTime?
 | 
					  creationDate DateTime?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  albumId String?
 | 
					  albumId String?
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										219
									
								
								src/actions/portfolio/getPortfolioImagesPage.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								src/actions/portfolio/getPortfolioImagesPage.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,219 @@
 | 
				
			|||||||
 | 
					// "use server";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// import type { Prisma } from "@/generated/prisma"; // <-- types, no "any"
 | 
				
			||||||
 | 
					// import prisma from "@/lib/prisma";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// export type Cursor = { afterSortKey: number; afterId: string } | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// export type GalleryItem = {
 | 
				
			||||||
 | 
					//   id: string;
 | 
				
			||||||
 | 
					//   url: string;
 | 
				
			||||||
 | 
					//   altText: string;
 | 
				
			||||||
 | 
					//   backgroundColor: string;
 | 
				
			||||||
 | 
					//   width: number;
 | 
				
			||||||
 | 
					//   height: number;
 | 
				
			||||||
 | 
					//   fullUrl: string;
 | 
				
			||||||
 | 
					//   fullWidth: number;
 | 
				
			||||||
 | 
					//   fullHeight: number;
 | 
				
			||||||
 | 
					//   sortKey: number;
 | 
				
			||||||
 | 
					//   fileKey: string;
 | 
				
			||||||
 | 
					// };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// export async function getPortfolioImagesPage(args: {
 | 
				
			||||||
 | 
					//   take?: number;
 | 
				
			||||||
 | 
					//   cursor?: Cursor;
 | 
				
			||||||
 | 
					//   albumId?: string | null;
 | 
				
			||||||
 | 
					//   year?: number | string | null;
 | 
				
			||||||
 | 
					//   typeSlug?: string | null;
 | 
				
			||||||
 | 
					//   onlyPublished?: boolean;
 | 
				
			||||||
 | 
					// }): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
 | 
				
			||||||
 | 
					//   const {
 | 
				
			||||||
 | 
					//     take = 60,
 | 
				
			||||||
 | 
					//     cursor = null,
 | 
				
			||||||
 | 
					//     albumId = null,
 | 
				
			||||||
 | 
					//     year = null,
 | 
				
			||||||
 | 
					//     typeSlug = null,
 | 
				
			||||||
 | 
					//     onlyPublished = true,
 | 
				
			||||||
 | 
					//   } = args;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   const coercedYear =
 | 
				
			||||||
 | 
					//     typeof year === "string" && year.trim() !== "" ? Number(year) :
 | 
				
			||||||
 | 
					//     typeof year === "number" ? year : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   const where: Prisma.PortfolioImageWhereInput = {
 | 
				
			||||||
 | 
					//     sortKey: { not: null },
 | 
				
			||||||
 | 
					//     variants: { some: { type: "resized" } },
 | 
				
			||||||
 | 
					//     ...(onlyPublished ? { published: true } : {}),
 | 
				
			||||||
 | 
					//     ...(albumId ? { albumId } : {}),
 | 
				
			||||||
 | 
					//     ...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
 | 
				
			||||||
 | 
					//     ...(typeSlug ? { type: { is: { slug: typeSlug } } } : {}),
 | 
				
			||||||
 | 
					//   };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   if (cursor) {
 | 
				
			||||||
 | 
					//     const sk = Number(cursor.afterSortKey);
 | 
				
			||||||
 | 
					//     (where as Prisma.PortfolioImageWhereInput).OR = [
 | 
				
			||||||
 | 
					//       { sortKey: { gt: sk } },
 | 
				
			||||||
 | 
					//       { AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
 | 
				
			||||||
 | 
					//     ];
 | 
				
			||||||
 | 
					//   }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   const rows = await prisma.portfolioImage.findMany({
 | 
				
			||||||
 | 
					//     where,
 | 
				
			||||||
 | 
					//     orderBy: [{ sortKey: "asc" }, { id: "asc" }],
 | 
				
			||||||
 | 
					//     take: Math.min(take, 200),
 | 
				
			||||||
 | 
					//     select: {
 | 
				
			||||||
 | 
					//       id: true,
 | 
				
			||||||
 | 
					//       fileKey: true,
 | 
				
			||||||
 | 
					//       altText: true,
 | 
				
			||||||
 | 
					//       name: true,
 | 
				
			||||||
 | 
					//       sortKey: true,
 | 
				
			||||||
 | 
					//       variants: {
 | 
				
			||||||
 | 
					//         select: { type: true, url: true, width: true, height: true },
 | 
				
			||||||
 | 
					//         where: { type: { in: ["resized", "modified"] } },
 | 
				
			||||||
 | 
					//       },
 | 
				
			||||||
 | 
					//       colors: {
 | 
				
			||||||
 | 
					//         where: { type: "Vibrant" },
 | 
				
			||||||
 | 
					//         select: { color: { select: { hex: true } } },
 | 
				
			||||||
 | 
					//         take: 1,
 | 
				
			||||||
 | 
					//       },
 | 
				
			||||||
 | 
					//     },
 | 
				
			||||||
 | 
					//   });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   const items: GalleryItem[] = rows
 | 
				
			||||||
 | 
					//     .map((r): GalleryItem | null => {
 | 
				
			||||||
 | 
					//       const resized = r.variants.find(v => v.type === "resized");
 | 
				
			||||||
 | 
					//       if (!resized?.width || !resized.height) return null;
 | 
				
			||||||
 | 
					//       const full = r.variants.find(v => v.type === "modified");
 | 
				
			||||||
 | 
					//       const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//       return {
 | 
				
			||||||
 | 
					//         id: r.id,
 | 
				
			||||||
 | 
					//         fileKey: r.fileKey,
 | 
				
			||||||
 | 
					//         altText: r.altText ?? r.name ?? "",
 | 
				
			||||||
 | 
					//         backgroundColor: bg,
 | 
				
			||||||
 | 
					//         width: resized.width,
 | 
				
			||||||
 | 
					//         height: resized.height,
 | 
				
			||||||
 | 
					//         // use your existing API route for images:
 | 
				
			||||||
 | 
					//         url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
 | 
				
			||||||
 | 
					//         fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
 | 
				
			||||||
 | 
					//         fullWidth: full?.width ?? 0,
 | 
				
			||||||
 | 
					//         fullHeight: full?.height ?? 0,
 | 
				
			||||||
 | 
					//         sortKey: r.sortKey ?? 0,
 | 
				
			||||||
 | 
					//       };
 | 
				
			||||||
 | 
					//     })
 | 
				
			||||||
 | 
					//     .filter((x): x is GalleryItem => x !== null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   const last = rows[rows.length - 1];
 | 
				
			||||||
 | 
					//   const nextCursor: Cursor =
 | 
				
			||||||
 | 
					//     rows.length < Math.min(take, 200) || !last
 | 
				
			||||||
 | 
					//       ? null
 | 
				
			||||||
 | 
					//       : { afterSortKey: (last.sortKey as number), afterId: last.id };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//   return { items, nextCursor };
 | 
				
			||||||
 | 
					// }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					"use server";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import prisma from "@/lib/prisma";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type Cursor = { afterSortKey: number; afterId: string } | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type GalleryItem = {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  altText: string;
 | 
				
			||||||
 | 
					  backgroundColor: string;
 | 
				
			||||||
 | 
					  width: number;
 | 
				
			||||||
 | 
					  height: number;
 | 
				
			||||||
 | 
					  fullUrl: string;
 | 
				
			||||||
 | 
					  fullWidth: number;
 | 
				
			||||||
 | 
					  fullHeight: number;
 | 
				
			||||||
 | 
					  sortKey: number;
 | 
				
			||||||
 | 
					  fileKey: string;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type FindManyArgs = Parameters<typeof prisma.portfolioImage.findMany>[0];
 | 
				
			||||||
 | 
					type WhereInput = NonNullable<FindManyArgs>["where"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function getPortfolioImagesPage(args: {
 | 
				
			||||||
 | 
					  take?: number;
 | 
				
			||||||
 | 
					  cursor?: Cursor;
 | 
				
			||||||
 | 
					  albumId?: string | null;
 | 
				
			||||||
 | 
					  year?: number | string | null;
 | 
				
			||||||
 | 
					}): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
 | 
				
			||||||
 | 
					  const { take = 60, cursor = null, albumId = null, year = null } = args;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const coercedYear =
 | 
				
			||||||
 | 
					    typeof year === "string" && year.trim() !== "" ? Number(year) :
 | 
				
			||||||
 | 
					    typeof year === "number" ? year : undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const where: WhereInput = {
 | 
				
			||||||
 | 
					    sortKey: { not: null },
 | 
				
			||||||
 | 
					    published: true,
 | 
				
			||||||
 | 
					    variants: { some: { type: "resized" } },
 | 
				
			||||||
 | 
					    ...(albumId ? { albumId } : {}),
 | 
				
			||||||
 | 
					    ...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (cursor) {
 | 
				
			||||||
 | 
					    const sk = Number(cursor.afterSortKey);
 | 
				
			||||||
 | 
					    (where as WhereInput).OR = [
 | 
				
			||||||
 | 
					      { sortKey: { gt: sk } },
 | 
				
			||||||
 | 
					      { AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const rows = await prisma.portfolioImage.findMany({
 | 
				
			||||||
 | 
					    where,
 | 
				
			||||||
 | 
					    orderBy: [{ sortKey: "asc" }, { id: "asc" }],
 | 
				
			||||||
 | 
					    take: Math.min(take, 200),
 | 
				
			||||||
 | 
					    select: {
 | 
				
			||||||
 | 
					      id: true,
 | 
				
			||||||
 | 
					      fileKey: true,
 | 
				
			||||||
 | 
					      altText: true,
 | 
				
			||||||
 | 
					      name: true,
 | 
				
			||||||
 | 
					      sortKey: true,
 | 
				
			||||||
 | 
					      variants: {
 | 
				
			||||||
 | 
					        select: { type: true, url: true, width: true, height: true },
 | 
				
			||||||
 | 
					        where: { type: { in: ["resized", "modified"] } },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      colors: {
 | 
				
			||||||
 | 
					        where: { type: "Vibrant" },
 | 
				
			||||||
 | 
					        select: { color: { select: { hex: true } } },
 | 
				
			||||||
 | 
					        take: 1,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const items: GalleryItem[] = rows
 | 
				
			||||||
 | 
					    .map((r): GalleryItem | null => {
 | 
				
			||||||
 | 
					      const resized = r.variants.find(v => v.type === "resized");
 | 
				
			||||||
 | 
					      if (!resized?.width || !resized.height) return null;
 | 
				
			||||||
 | 
					      const full = r.variants.find(v => v.type === "modified");
 | 
				
			||||||
 | 
					      const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        id: r.id,
 | 
				
			||||||
 | 
					        fileKey: r.fileKey,
 | 
				
			||||||
 | 
					        altText: r.altText ?? r.name ?? "",
 | 
				
			||||||
 | 
					        backgroundColor: bg,
 | 
				
			||||||
 | 
					        width: resized.width,
 | 
				
			||||||
 | 
					        height: resized.height,
 | 
				
			||||||
 | 
					        // use your existing API route
 | 
				
			||||||
 | 
					        url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
 | 
				
			||||||
 | 
					        fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
 | 
				
			||||||
 | 
					        fullWidth: full?.width ?? 0,
 | 
				
			||||||
 | 
					        fullHeight: full?.height ?? 0,
 | 
				
			||||||
 | 
					        sortKey: r.sortKey ?? 0,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    .filter((x): x is GalleryItem => x !== null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const last = rows[rows.length - 1];
 | 
				
			||||||
 | 
					  const nextCursor: Cursor =
 | 
				
			||||||
 | 
					    rows.length < Math.min(take, 200) || !last
 | 
				
			||||||
 | 
					      ? null
 | 
				
			||||||
 | 
					      : { afterSortKey: (last.sortKey as number), afterId: last.id };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return { items, nextCursor };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										15
									
								
								src/app/portfolio/art-v1/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/app/portfolio/art-v1/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
 | 
				
			||||||
 | 
					import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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">
 | 
				
			||||||
 | 
					      <JustifiedGallery images={images} rowHeight={340} gap={12} />
 | 
				
			||||||
 | 
					    </main>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,15 +1,19 @@
 | 
				
			|||||||
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
 | 
					import JustifiedGalleryV2 from "@/components/portfolio/JustifiedGalleryV2";
 | 
				
			||||||
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
 | 
					import prisma from "@/lib/prisma";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
 | 
					export default async function ArtPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
 | 
				
			||||||
  const { year, album } = await searchParams;
 | 
					  const { year, album } = await searchParams;
 | 
				
			||||||
  const images = await getJustifiedImages(year, album, "art", false);
 | 
					  const images = await prisma.portfolioImage.findMany({
 | 
				
			||||||
 | 
					    orderBy: [{ sortKey: "asc" }, { id: "asc" }],
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!images) return null
 | 
					  if (!images) return null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // console.log(images)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <main className="p-2 mx-auto max-w-screen-2xl">
 | 
					    <main className="p-2 mx-auto max-w-screen-2xl">
 | 
				
			||||||
      <JustifiedGallery images={images} rowHeight={340} gap={12} />
 | 
					      <JustifiedGalleryV2 albumId={album} year={year} />
 | 
				
			||||||
    </main>
 | 
					    </main>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,6 +1,10 @@
 | 
				
			|||||||
import prisma from "@/lib/prisma";
 | 
					import prisma from "@/lib/prisma";
 | 
				
			||||||
 | 
					import { cn } from "@/lib/utils";
 | 
				
			||||||
 | 
					import { Pacifico } from "next/font/google";
 | 
				
			||||||
import Image from "next/image";
 | 
					import Image from "next/image";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const pacifico = Pacifico({ weight: "400", subsets: ["latin"] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default async function Banner() {
 | 
					export default async function Banner() {
 | 
				
			||||||
  const headerImage = await prisma.portfolioImage.findFirst({
 | 
					  const headerImage = await prisma.portfolioImage.findFirst({
 | 
				
			||||||
    where: { setAsHeader: true },
 | 
					    where: { setAsHeader: true },
 | 
				
			||||||
@ -28,7 +32,7 @@ export default async function Banner() {
 | 
				
			|||||||
          />
 | 
					          />
 | 
				
			||||||
          {/* Overlay Logo / Title */}
 | 
					          {/* Overlay Logo / Title */}
 | 
				
			||||||
          <div className="absolute inset-0 bg-black/40 flex items-center justify-center text-center">
 | 
					          <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">
 | 
					            <h1 className={cn(pacifico.className, "text-shadow text-white text-3xl md:text-5xl font-bold drop-shadow")}>
 | 
				
			||||||
              {title}
 | 
					              {title}
 | 
				
			||||||
            </h1>
 | 
					            </h1>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
export default function Footer() {
 | 
					export default function Footer() {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div>Footer</div>
 | 
					    <div>©️ 2025 by gaertan.art | All rights reserved</div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										269
									
								
								src/components/portfolio/JustifiedGalleryV2.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								src/components/portfolio/JustifiedGalleryV2.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,269 @@
 | 
				
			|||||||
 | 
					"use client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { Cursor, GalleryItem, getPortfolioImagesPage } from "@/actions/portfolio/getPortfolioImagesPage";
 | 
				
			||||||
 | 
					import * as React from "react";
 | 
				
			||||||
 | 
					import { ImageCard } from "./ImageCard";
 | 
				
			||||||
 | 
					import { Lightbox } from "./Lightbox";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type JLItem = { id: string; w: number; h: number };
 | 
				
			||||||
 | 
					type JLBox = JLItem & { renderW: number; renderH: number; row: number };
 | 
				
			||||||
 | 
					type FillMode = "ragged" | "distribute";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function justifiedLayout(
 | 
				
			||||||
 | 
					  items: JLItem[],
 | 
				
			||||||
 | 
					  containerWidth: number,
 | 
				
			||||||
 | 
					  targetRowH = 260,
 | 
				
			||||||
 | 
					  gap = 8,
 | 
				
			||||||
 | 
					  maxRowScale = 1.25,
 | 
				
			||||||
 | 
					  fillMode: FillMode = "ragged",   // <— new
 | 
				
			||||||
 | 
					): JLBox[] {
 | 
				
			||||||
 | 
					  const out: JLBox[] = [];
 | 
				
			||||||
 | 
					  if (containerWidth <= 0) return out;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let row: JLItem[] = [];
 | 
				
			||||||
 | 
					  let sumAspect = 0;
 | 
				
			||||||
 | 
					  let rowIndex = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const flush = (isLast: boolean) => {
 | 
				
			||||||
 | 
					    if (!row.length) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const gapsTotal = gap * (row.length - 1);
 | 
				
			||||||
 | 
					    // Height that would exactly fit if we justified
 | 
				
			||||||
 | 
					    const rawH = (containerWidth - gapsTotal) / sumAspect;
 | 
				
			||||||
 | 
					    // Normal case: set row height close to target, but never exceed maxRowScale
 | 
				
			||||||
 | 
					    const baseH = Math.min(
 | 
				
			||||||
 | 
					      isLast ? Math.min(targetRowH, rawH) : rawH,
 | 
				
			||||||
 | 
					      targetRowH * maxRowScale
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const exact = row.map(it => baseH * (it.w / it.h));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (fillMode === "distribute" && !isLast) {
 | 
				
			||||||
 | 
					      // Fill the row exactly by distributing rounding error fairly
 | 
				
			||||||
 | 
					      const floor = exact.map(Math.floor);
 | 
				
			||||||
 | 
					      const used = floor.reduce((a, b) => a + b, 0) + gap * (row.length - 1);
 | 
				
			||||||
 | 
					      let remaining = Math.max(0, containerWidth - used);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const fracs = exact.map((v, i) => ({ i, frac: v - floor[i] }));
 | 
				
			||||||
 | 
					      fracs.sort((a, b) => b.frac - a.frac);
 | 
				
			||||||
 | 
					      const widths = floor.slice();
 | 
				
			||||||
 | 
					      for (let k = 0; k < widths.length && remaining > 0; k++) {
 | 
				
			||||||
 | 
					        widths[fracs[k].i] += 1;
 | 
				
			||||||
 | 
					        remaining--;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      widths[widths.length - 1] += remaining; // just in case
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      widths.forEach((w, i) => {
 | 
				
			||||||
 | 
					        out.push({ ...row[i], renderW: w, renderH: Math.round(baseH), row: rowIndex });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      // RAGGED: do not force the row to fill the width
 | 
				
			||||||
 | 
					      exact.forEach((w, i) => {
 | 
				
			||||||
 | 
					        out.push({ ...row[i], renderW: Math.round(w), renderH: Math.round(baseH), row: rowIndex });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    row = [];
 | 
				
			||||||
 | 
					    sumAspect = 0;
 | 
				
			||||||
 | 
					    rowIndex++;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (let i = 0; i < items.length; i++) {
 | 
				
			||||||
 | 
					    const it = items[i];
 | 
				
			||||||
 | 
					    const ar = it.w / it.h;
 | 
				
			||||||
 | 
					    const testSum = sumAspect + ar;
 | 
				
			||||||
 | 
					    const wouldH = (containerWidth - gap * (row.length)) / testSum;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If adding this item would drop row height below target, flush current row
 | 
				
			||||||
 | 
					    if (row.length > 0 && wouldH < targetRowH) flush(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    row.push(it);
 | 
				
			||||||
 | 
					    sumAspect += ar;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  flush(true); // last row (never fully justified in ragged mode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return out;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function JustifiedGallery({
 | 
				
			||||||
 | 
					  albumId = null,
 | 
				
			||||||
 | 
					  year = null,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  albumId?: string | null;
 | 
				
			||||||
 | 
					  year?: string | number | null;
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  const [items, setItems] = React.useState<GalleryItem[]>([]);
 | 
				
			||||||
 | 
					  const [cursor, setCursor] = React.useState<Cursor>(null);
 | 
				
			||||||
 | 
					  const [done, setDone] = React.useState(false);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = React.useState(false);
 | 
				
			||||||
 | 
					  const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const inFlight = React.useRef(false);
 | 
				
			||||||
 | 
					  const doneRef = React.useRef(false);
 | 
				
			||||||
 | 
					  doneRef.current = done;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const containerRef = React.useRef<HTMLDivElement>(null);
 | 
				
			||||||
 | 
					  const [containerW, setContainerW] = React.useState(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  React.useEffect(() => {
 | 
				
			||||||
 | 
					    const el = containerRef.current;
 | 
				
			||||||
 | 
					    if (!el) return;
 | 
				
			||||||
 | 
					    const ro = new ResizeObserver(([e]) => setContainerW(Math.floor(e.contentRect.width)));
 | 
				
			||||||
 | 
					    ro.observe(el);
 | 
				
			||||||
 | 
					    return () => ro.disconnect();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const GAP = 16;
 | 
				
			||||||
 | 
					  const ROW_HEIGHT = 340;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const sentinelRef = React.useRef<HTMLDivElement>(null);
 | 
				
			||||||
 | 
					  const ioRef = React.useRef<IntersectionObserver | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const loadMore = React.useCallback(async () => {
 | 
				
			||||||
 | 
					    if (inFlight.current || doneRef.current) return 0;
 | 
				
			||||||
 | 
					    inFlight.current = true;
 | 
				
			||||||
 | 
					    setLoading(true);
 | 
				
			||||||
 | 
					    const sentinel = sentinelRef.current;
 | 
				
			||||||
 | 
					    if (sentinel && ioRef.current) ioRef.current.unobserve(sentinel);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const data = await getPortfolioImagesPage({ take: 60, cursor, albumId, year });
 | 
				
			||||||
 | 
					      setItems(prev => prev.concat(data.items));
 | 
				
			||||||
 | 
					      setCursor(data.nextCursor);
 | 
				
			||||||
 | 
					      if (!data.nextCursor) setDone(true);
 | 
				
			||||||
 | 
					      return data.items.length;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					      inFlight.current = false;
 | 
				
			||||||
 | 
					      if (!doneRef.current && sentinel && ioRef.current) ioRef.current.observe(sentinel);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [cursor, albumId, year]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // initial + when filters change
 | 
				
			||||||
 | 
					  React.useEffect(() => {
 | 
				
			||||||
 | 
					    setItems([]); setCursor(null); setDone(false); doneRef.current = false; inFlight.current = false;
 | 
				
			||||||
 | 
					    void loadMore();
 | 
				
			||||||
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
 | 
					  }, [albumId, year]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // IO wiring
 | 
				
			||||||
 | 
					  React.useEffect(() => {
 | 
				
			||||||
 | 
					    const sentinel = sentinelRef.current;
 | 
				
			||||||
 | 
					    if (!sentinel) return;
 | 
				
			||||||
 | 
					    ioRef.current?.disconnect();
 | 
				
			||||||
 | 
					    ioRef.current = new IntersectionObserver((entries) => {
 | 
				
			||||||
 | 
					      if (entries.some(e => e.isIntersecting)) {
 | 
				
			||||||
 | 
					        ioRef.current?.unobserve(sentinel);
 | 
				
			||||||
 | 
					        void loadMore();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }, { rootMargin: "600px 0px", threshold: 0.01 });
 | 
				
			||||||
 | 
					    ioRef.current.observe(sentinel);
 | 
				
			||||||
 | 
					    return () => ioRef.current?.disconnect();
 | 
				
			||||||
 | 
					  }, [loadMore]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // layout boxes
 | 
				
			||||||
 | 
					  const boxes = React.useMemo(() => {
 | 
				
			||||||
 | 
					    const base: JLItem[] = items.map(i => ({ id: i.id, w: i.width, h: i.height }));
 | 
				
			||||||
 | 
					    return justifiedLayout(base, containerW, ROW_HEIGHT, GAP, 1.25, 'distribute');
 | 
				
			||||||
 | 
					  }, [items, containerW, ROW_HEIGHT, GAP]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // quick lookups
 | 
				
			||||||
 | 
					  const idToIndex = React.useMemo(() => new Map(items.map((it, idx) => [it.id, idx])), [items]);
 | 
				
			||||||
 | 
					  const idToMeta = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // open lightbox at the global (items) index
 | 
				
			||||||
 | 
					  const onTileClick = React.useCallback((id: string) => {
 | 
				
			||||||
 | 
					    const idx = idToIndex.get(id);
 | 
				
			||||||
 | 
					    if (idx !== undefined) setSelectedIndex(idx);
 | 
				
			||||||
 | 
					  }, [idToIndex]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // modal navigation
 | 
				
			||||||
 | 
					  const hasPrev = selectedIndex !== null && selectedIndex > 0;
 | 
				
			||||||
 | 
					  const hasNextLoaded = selectedIndex !== null && selectedIndex < items.length - 1;
 | 
				
			||||||
 | 
					  const hasNextPotential = !done; // if not done, we might be able to load more
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onPrev = React.useCallback(() => {
 | 
				
			||||||
 | 
					    setSelectedIndex(i => (i !== null && i > 0 ? i - 1 : i));
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onNext = React.useCallback(async () => {
 | 
				
			||||||
 | 
					    // Use a snapshot of the current selected index
 | 
				
			||||||
 | 
					    const current = selectedIndex;
 | 
				
			||||||
 | 
					    if (current === null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If next image is already loaded
 | 
				
			||||||
 | 
					    if (current < items.length - 1) {
 | 
				
			||||||
 | 
					      setSelectedIndex(current + 1);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // If we're at the end but more pages exist
 | 
				
			||||||
 | 
					    if (!doneRef.current) {
 | 
				
			||||||
 | 
					      const added = await loadMore();
 | 
				
			||||||
 | 
					      if (added > 0) {
 | 
				
			||||||
 | 
					        setSelectedIndex(current + 1);
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // No more images
 | 
				
			||||||
 | 
					  }, [selectedIndex, items.length, loadMore]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <>
 | 
				
			||||||
 | 
					      <div ref={containerRef} className="w-full flex flex-col" style={{ rowGap: `${GAP}px` }}>
 | 
				
			||||||
 | 
					        {boxes.length === 0 && !loading && (
 | 
				
			||||||
 | 
					          <p className="text-muted-foreground text-center py-20">No images to display</p>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* render by rows */}
 | 
				
			||||||
 | 
					        {(() => {
 | 
				
			||||||
 | 
					          const rows: Record<number, JLBox[]> = {};
 | 
				
			||||||
 | 
					          for (const b of boxes) (rows[b.row] ??= []).push(b);
 | 
				
			||||||
 | 
					          return Object.keys(rows).map((rowKey) => {
 | 
				
			||||||
 | 
					            const row = rows[Number(rowKey)];
 | 
				
			||||||
 | 
					            return (
 | 
				
			||||||
 | 
					              <div key={rowKey} className="flex" style={{ columnGap: `${GAP}px` }}>
 | 
				
			||||||
 | 
					                {row.map((b) => {
 | 
				
			||||||
 | 
					                  const meta = idToMeta.get(b.id)!;
 | 
				
			||||||
 | 
					                  return (
 | 
				
			||||||
 | 
					                    <div
 | 
				
			||||||
 | 
					                      key={b.id}
 | 
				
			||||||
 | 
					                      style={{ width: b.renderW, height: b.renderH }}
 | 
				
			||||||
 | 
					                      onClick={() => onTileClick(b.id)}
 | 
				
			||||||
 | 
					                      className="cursor-zoom-in"
 | 
				
			||||||
 | 
					                    >
 | 
				
			||||||
 | 
					                      <ImageCard
 | 
				
			||||||
 | 
					                        image={{
 | 
				
			||||||
 | 
					                          id: meta.id,
 | 
				
			||||||
 | 
					                          altText: meta.altText,
 | 
				
			||||||
 | 
					                          url: meta.url,
 | 
				
			||||||
 | 
					                          backgroundColor: meta.backgroundColor,
 | 
				
			||||||
 | 
					                        }}
 | 
				
			||||||
 | 
					                        variant="mosaic"
 | 
				
			||||||
 | 
					                      />
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                  );
 | 
				
			||||||
 | 
					                })}
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        })()}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {!done && <div ref={sentinelRef} style={{ height: 1 }} />}
 | 
				
			||||||
 | 
					        {loading && <p className="text-sm text-muted-foreground mt-2">Loading…</p>}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* Lightbox modal */}
 | 
				
			||||||
 | 
					      {selectedIndex !== null && (
 | 
				
			||||||
 | 
					        <Lightbox
 | 
				
			||||||
 | 
					          image={items[selectedIndex]}
 | 
				
			||||||
 | 
					          onClose={() => setSelectedIndex(null)}
 | 
				
			||||||
 | 
					          hasPrev={hasPrev}
 | 
				
			||||||
 | 
					          hasNext={hasNextLoaded || hasNextPotential}
 | 
				
			||||||
 | 
					          onPrev={onPrev}
 | 
				
			||||||
 | 
					          onNext={onNext}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										58
									
								
								src/utils/justifiedLayout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/utils/justifiedLayout.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					export type JLItem = { id: string; w: number; h: number }; // original size
 | 
				
			||||||
 | 
					export type JLBox = JLItem & { renderW: number; renderH: number, row: number };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function justifiedLayout(
 | 
				
			||||||
 | 
					  items: JLItem[],
 | 
				
			||||||
 | 
					  containerWidth: number,
 | 
				
			||||||
 | 
					  targetRowH = 260,
 | 
				
			||||||
 | 
					  gap = 8,
 | 
				
			||||||
 | 
					  maxRowScale = 1.25 // how tall a row may scale vs target
 | 
				
			||||||
 | 
					): JLBox[] {
 | 
				
			||||||
 | 
					  const out: JLBox[] = [];
 | 
				
			||||||
 | 
					  if (containerWidth <= 0) return out;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let row: JLItem[] = [];
 | 
				
			||||||
 | 
					  let rowAspectSum = 0;
 | 
				
			||||||
 | 
					  let rowIndex = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const flushRow = (isLast: boolean) => {
 | 
				
			||||||
 | 
					    if (row.length === 0) return;
 | 
				
			||||||
 | 
					    const gaps = gap * (row.length - 1);
 | 
				
			||||||
 | 
					    const rawH = containerWidth / rowAspectSum; // height that would exactly fit the width
 | 
				
			||||||
 | 
					    // For the very last row, keep it near target height to avoid huge upscaling
 | 
				
			||||||
 | 
					    const targetH = isLast ? Math.min(targetRowH, rawH) : rawH;
 | 
				
			||||||
 | 
					    const clampedH = Math.min(targetH, targetRowH * maxRowScale);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let used = 0;
 | 
				
			||||||
 | 
					    row.forEach((it, i) => {
 | 
				
			||||||
 | 
					      const ar = it.w / it.h;
 | 
				
			||||||
 | 
					      let w = Math.round(clampedH * ar);
 | 
				
			||||||
 | 
					      // Put gap between items; last item stretches to fix rounding drift
 | 
				
			||||||
 | 
					      if (i === row.length - 1) w = containerWidth - used - gaps;
 | 
				
			||||||
 | 
					      out.push({ ...it, renderW: w, renderH: Math.round(clampedH), row: rowIndex });
 | 
				
			||||||
 | 
					      used += w + (i < row.length - 1 ? gap : 0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    row = [];
 | 
				
			||||||
 | 
					    rowAspectSum = 0;
 | 
				
			||||||
 | 
					    rowIndex += 1;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  for (let i = 0; i < items.length; i++) {
 | 
				
			||||||
 | 
					    const it = items[i];
 | 
				
			||||||
 | 
					    const ar = it.w / it.h;
 | 
				
			||||||
 | 
					    const testAspectSum = rowAspectSum + ar;
 | 
				
			||||||
 | 
					    const rawH = containerWidth / testAspectSum;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // When adding this item would push row height below target, close the row
 | 
				
			||||||
 | 
					    if (row.length > 0 && rawH < targetRowH) {
 | 
				
			||||||
 | 
					      flushRow(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    row.push(it);
 | 
				
			||||||
 | 
					    rowAspectSum += ar;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  flushRow(true); // last row
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return out;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -38,4 +38,4 @@
 | 
				
			|||||||
  "exclude": [
 | 
					  "exclude": [
 | 
				
			||||||
    "node_modules"
 | 
					    "node_modules"
 | 
				
			||||||
  ]
 | 
					  ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user