Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 72 additions & 32 deletions src/app/(public)/gallery/[albumId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,67 @@
"use client";

import { useState, use, useRef, useEffect, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import ImageDisplay, { useImageBlobActions } from "@/components/ImageDisplay";
import type { AlbumRead } from "@/api";
import {
getAlbumImagesOptions,
getOneAlbumOptions,
} from "@/api/@tanstack/react-query.gen";
import type { AlbumRead } from "@/api";
import ImageDisplay, { useImageBlobActions } from "@/components/ImageDisplay";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useInViewport } from "@/hooks/useInViewport";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ArrowRight, Download, Maximize2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { use, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";

interface Props {
params: Promise<{ albumId: string }>;
}

// Lazy loaded image component for the gallery grid
function LazyImageCard({
img,
idx,
album,
onOpen,
}: {
img: number;
idx: number;
album?: AlbumRead;
onOpen: () => void;
}) {
const [ref, isInView] = useInViewport<HTMLButtonElement>("200px");

return (
<button
ref={ref}
type="button"
className="relative rounded overflow-hidden shadow cursor-pointer p-0 border border-opacity-30 focus:outline-none focus:ring-4 focus:ring-primary"
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpen();
}}
>
<div className="relative w-full aspect-[4/3]">
{isInView ? (
<ImageDisplay
type="image"
imageId={img}
size="medium"
alt={`Image ${idx + 1} from album ${album?.title_en}`}
fill
style={{ objectFit: "cover" }}
/>
) : (
// Placeholder while image is not in view
<Skeleton className="w-full h-full" />
)}
</div>
</button>
);
}

export default function AlbumPage({ params }: Props) {
const resolvedParams = use(params);
const albumId = Number(resolvedParams.albumId);
Expand Down Expand Up @@ -85,7 +130,7 @@ export default function AlbumPage({ params }: Props) {

useEffect(() => {
updateBox();
}, [imgNatural, updateBox]);
}, [updateBox]);

const openViewer = (idx: number) => {
setViewerIndex(idx);
Expand All @@ -96,21 +141,29 @@ export default function AlbumPage({ params }: Props) {
} catch {}
};

const closeViewer = () => {
const closeViewer = useCallback(() => {
setViewerIndex(null);
try {
const url = new URL(window.location.href);
url.searchParams.delete("img");
router.replace(url.toString());
} catch {}
};
}, [router]);

// Move to the next image in the viewer, unless already at the last image
const next = () =>
setViewerIndex((i) => (i == null ? 0 : i < images.length - 1 ? i + 1 : i));
const next = useCallback(
() =>
setViewerIndex((i) =>
i == null ? 0 : i < images.length - 1 ? i + 1 : i,
),
[images.length],
);

// Move to the previous image in the viewer, unless already at the first image
const prev = () => setViewerIndex((i) => (i == null ? 0 : i > 0 ? i - 1 : i));
const prev = useCallback(
() => setViewerIndex((i) => (i == null ? 0 : i > 0 ? i - 1 : i)),
[],
);

const handleDownload = () => {
// Download using blob URL using a method we get from ImageDisplay
Expand Down Expand Up @@ -140,7 +193,7 @@ export default function AlbumPage({ params }: Props) {
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [viewerIndex]);
}, [viewerIndex, prev, next, closeViewer]);

return (
<div className="px-6 py-6 xl:mx-[4%]">
Expand Down Expand Up @@ -186,26 +239,13 @@ export default function AlbumPage({ params }: Props) {
</div>

{images.map((img, idx) => (
<button
<LazyImageCard
key={img}
type="button"
className="relative rounded overflow-hidden shadow cursor-pointer p-0 border border-opacity-30 focus:outline-none focus:ring-4 focus:ring-primary"
onClick={() => openViewer(idx)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openViewer(idx);
}}
>
<div className="relative w-full aspect-[4/3]">
<ImageDisplay
type="image"
imageId={img}
size="medium"
alt={`Image ${idx + 1} from album ${album?.title_en}`}
fill
style={{ objectFit: "cover" }}
/>
</div>
</button>
img={img}
idx={idx}
album={album}
onOpen={() => openViewer(idx)}
/>
))}
</div>

Expand Down Expand Up @@ -326,7 +366,7 @@ export default function AlbumPage({ params }: Props) {
{(() => {
const maxThumbs = 3;
const total = images.length;
const idx = viewerIndex!;
const idx = viewerIndex ?? 0;
const start = Math.max(0, idx - maxThumbs);
const end = Math.min(total, idx + maxThumbs + 1);
const leftHidden = start;
Expand Down
18 changes: 10 additions & 8 deletions src/components/ImageDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"use client";

import type React from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getImageStreamOptions,
getImageOptions,
getImageStreamOptions,
getNewsImageOptions,
getNewsImageStreamOptions,
} from "@/api/@tanstack/react-query.gen";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
import type { ImageProps as NextImageProps } from "next/image";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";

export type ImageKind = "image" | "news" | "event" | "user";
export type ImageSize = "small" | "medium" | "large" | "original";
Expand All @@ -19,6 +19,7 @@ export interface ImageDisplayProps extends Omit<NextImageProps, "src" | "id"> {
type: ImageKind;
imageId: number;
size?: ImageSize;
enabled?: boolean; // Control when the image query should run
}

// Helpers to get original blob and actions using stream routes
Expand Down Expand Up @@ -111,6 +112,7 @@ export default function ImageDisplay({
onClick,
onLoad,
onError,
enabled = true,
...imgProps
}: ImageDisplayProps) {
const [src, setSrc] = useState<string | undefined>(undefined);
Expand All @@ -126,15 +128,15 @@ export default function ImageDisplay({
if (devMode) {
queryOptions = {
...getNewsImageStreamOptions({ path: { news_id: imageId } }),
enabled: !!imageId,
enabled: !!imageId && enabled,
refetchOnWindowFocus: false,
};
} else {
queryOptions = {
...getNewsImageOptions({
path: { news_id: imageId, size: size },
}),
enabled: !!imageId,
enabled: !!imageId && enabled,
refetchOnWindowFocus: false,
};
}
Expand All @@ -145,15 +147,15 @@ export default function ImageDisplay({
if (devMode) {
queryOptions = {
...getImageStreamOptions({ path: { img_id: imageId } }),
enabled: !!imageId,
enabled: !!imageId && enabled,
refetchOnWindowFocus: false,
};
} else {
queryOptions = {
...getImageOptions({
path: { img_id: imageId, size: size },
}),
enabled: !!imageId,
enabled: !!imageId && enabled,
refetchOnWindowFocus: false,
};
}
Expand Down
41 changes: 41 additions & 0 deletions src/hooks/useInViewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";

/**
* Hook that detects when an element is in or near the viewport using IntersectionObserver
* @param rootMargin - Margin around the viewport to trigger early loading (default: "200px" for preloading)
* @returns [ref, isInView] - ref to attach to element and boolean indicating if element is in viewport
*/
export function useInViewport<T extends Element = HTMLDivElement>(
rootMargin = "200px",
): [React.RefObject<T>, boolean] {
const ref = useRef<T>(null);
const [isInView, setIsInView] = useState(false);

useEffect(() => {
const element = ref.current;
if (!element) return;

// Create observer
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
// Once the element is in view, we keep it loaded
if (entry.isIntersecting) {
setIsInView(true);
}
}
},
{
rootMargin, // Load images before they come into view
},
);

observer.observe(element);

return () => {
observer.disconnect();
};
}, [rootMargin]);

return [ref, isInView];
}