Add minor UX improvements
Build and push Docker image / build-and-push (push) Successful in 3m46s

This commit is contained in:
2026-08-31 05:35:53 +02:00
parent b11366fb4e
commit 7daf2b1783
9 changed files with 547 additions and 241 deletions
+65 -38
View File
@@ -4,52 +4,79 @@ import Topbar from '@/components/Topbar';
import { ImagesResponse, TagsResponse } from '@/interfaces/api'; import { ImagesResponse, TagsResponse } from '@/interfaces/api';
import { PaginatorPosition } from '@/interfaces/paginator'; import { PaginatorPosition } from '@/interfaces/paginator';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Suspense } from 'react';
export default async function GalleryPage({ params }: { params: Promise<{ page: number; tag: string }> }) { interface GalleryPageProps {
const { tag, page } = await params; params: Promise<{
tag: string;
page: string;
}>;
}
const fetchImages = async () => { export default async function GalleryPage({
const apiUrl = new URL( params,
`/api/images?page=${page}&tag=${tag === 'all' ? '' : tag}`, }: GalleryPageProps) {
process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000', const { tag, page: pageParam } = await params;
); const page = Number.parseInt(pageParam, 10);
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as ImagesResponse; if (!Number.isInteger(page) || page < 1) {
}; redirect(`/gallery/${encodeURIComponent(tag)}/1`);
const fetchTags = async () => {
const apiUrl = new URL('/api/tags', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000');
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as TagsResponse;
};
const images = await fetchImages();
const tags = await fetchTags();
if (images.totalPages < page && images.totalPages > 0) {
redirect(`/gallery/${tag}/${images.totalPages}`);
} }
if (page <= 0) { const siteUrl =
redirect(`/gallery/${tag}/1`); process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000';
const imagesUrl = new URL('/api/images', siteUrl);
imagesUrl.searchParams.set('page', String(page));
imagesUrl.searchParams.set('tag', tag === 'all' ? '' : tag);
const tagsUrl = new URL('/api/tags', siteUrl);
const [imagesResponse, tagsResponse] = await Promise.all([
fetch(imagesUrl, {
cache: 'no-store',
}),
fetch(tagsUrl, {
cache: 'no-store',
}),
]);
if (!imagesResponse.ok) {
throw new Error(
`Failed to load images: ${imagesResponse.status} ${imagesResponse.statusText}`,
);
}
if (!tagsResponse.ok) {
throw new Error(
`Failed to load tags: ${tagsResponse.status} ${tagsResponse.statusText}`,
);
}
const images = (await imagesResponse.json()) as ImagesResponse;
const tags = (await tagsResponse.json()) as TagsResponse;
if (images.totalPages > 0 && page > images.totalPages) {
redirect(
`/gallery/${encodeURIComponent(tag)}/${images.totalPages}`,
);
} }
return ( return (
<> <>
<Suspense fallback={<div>Loading gallery...</div>}> <Topbar
<Topbar activeTag={tag} tags={tags} page={page} totalPages={images.totalPages} /> activeTag={tag}
<Gallery initialImages={images.images} /> tags={tags}
<Paginator page={page} totalPages={images.totalPages} position={PaginatorPosition.BOTTOM} /> page={page}
</Suspense> totalPages={images.totalPages}
/>
<Gallery initialImages={images.images} />
<Paginator
page={page}
totalPages={images.totalPages}
position={PaginatorPosition.BOTTOM}
/>
</> </>
); );
} }
+10 -2
View File
@@ -1,5 +1,13 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ page: number; tag: string }> }) { interface TagPageProps {
redirect(`gallery/${(await params).tag}/1`); params: Promise<{
tag: string;
}>;
} }
export default async function TagPage({ params }: TagPageProps) {
const { tag } = await params;
redirect(`/gallery/${encodeURIComponent(tag)}/1`);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
export default async function Page() { export default async function Page() {
redirect(`gallery/all/1`); redirect(`/gallery/all/1`);
} }
+25 -11
View File
@@ -1,6 +1,8 @@
* { * {
--header-height: 50px; --header-height: 50px;
--color-accent: #3cdbc0; --color-accent: #3cdbc0;
--color-background: #0d1113;
--color-surface: #151b1d;
} }
:root { :root {
@@ -8,9 +10,9 @@
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: dark;
color: rgba(255, 255, 255, 0.87); color: rgba(255, 255, 255, 0.87);
background-color: rgb(27, 27, 27); background-color: var(--color-background);
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -18,22 +20,34 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body, html {
#root { min-height: 100%;
background-color: var(--color-background);
}
body {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh;
font-size: 16px;
letter-spacing: 1px;
scrollbar-gutter: stable;
background-color: var(--color-background);
background-image:
radial-gradient(circle at 85% 10%, rgba(60, 219, 192, 0.12) 0%, transparent 32%),
radial-gradient(circle at 10% 85%, rgba(27, 115, 124, 0.08) 0%, transparent 38%),
linear-gradient(145deg, #0d1113 0%, #101719 50%, #0b0e10 100%);
background-attachment: fixed;
}
#root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: 1px;
min-height: 100vh; min-height: 100vh;
scrollbar-gutter: stable;
background: linear-gradient(-120deg, rgba(0, 0, 0, 0), #06565a49);
} }
a { a {
text-decoration: none;
color: inherit; color: inherit;
text-decoration: none;
} }
+1
View File
@@ -4,6 +4,7 @@
padding: 0 40px; padding: 0 40px;
overflow: hidden; overflow: hidden;
background-image: url('/landing-page.jpg'); background-image: url('/landing-page.jpg');
min-height: calc(100vh - var(--header-height));
background-size: cover; background-size: cover;
@media (max-width: 1200px) { @media (max-width: 1200px) {
+154 -145
View File
@@ -3,185 +3,194 @@
import { ImageMeta } from '@/interfaces/image'; import { ImageMeta } from '@/interfaces/image';
import styles from '@/styles/Gallery.module.scss'; import styles from '@/styles/Gallery.module.scss';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter as useNavigationRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
interface GalleryProps { interface GalleryProps {
initialImages: ImageMeta[]; initialImages: ImageMeta[];
} }
export default function Gallery({ initialImages }: GalleryProps) { const HORIZONTAL_ASPECT_RATIO = 1.8;
const HORIZONTAL_ASPECT_RATIO = 1.8; const VERTICAL_ASPECT_RATIO = 0.8;
const VERTICAL_ASPECT_RATIO = 0.8;
const navigationRouter = useNavigationRouter(); export default function Gallery({
const searchParams = useSearchParams(); initialImages,
const [images, setImages] = useState<ImageMeta[]>([]); }: GalleryProps) {
const [columnCount, setColumnCount] = useState(0); const [fullScreenImageId, setFullScreenImageIdState] = useState<
const [fullScreenImageId, setFullScreenImageId] = useState<string | null>(null); string | null
>(null);
useEffect(() => { /*
const updateColumns = () => { * Update the URL without causing a Next.js navigation.
const imageStack = [...initialImages]; *
const newImages: ImageMeta[] = []; * router.replace() would render the server page again, causing
* /api/images and /api/tags to be requested again.
*/
const setFullScreenImage = useCallback((imageId: string | null) => {
setFullScreenImageIdState(imageId);
if (imageStack.length > 0) { const url = new URL(window.location.href);
let usedColumnsInRow = 0;
let usedRowsInColumn = 0;
let index = 0;
for (const image of imageStack) { if (imageId) {
usedColumnsInRow += image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1; url.searchParams.set('image', imageId);
usedRowsInColumn += image.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
if (usedColumnsInRow > columnCount) {
const nextViableImage: ImageMeta | undefined = imageStack.slice(index).find((img) => {
const imgAspectRatio = img.aspect_ratio;
return (
imgAspectRatio <= HORIZONTAL_ASPECT_RATIO &&
image.aspect_ratio >= VERTICAL_ASPECT_RATIO &&
!newImages.find((i) => i.id === img.id)
);
});
if (nextViableImage) {
newImages.push(nextViableImage);
usedColumnsInRow -= nextViableImage.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1;
usedRowsInColumn -= nextViableImage.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
}
} else if (usedColumnsInRow === columnCount) {
newImages.push(image);
usedColumnsInRow = usedRowsInColumn;
usedRowsInColumn = 0;
}
index++;
}
setImages(imageStack);
}
};
const updateColumnCount = () => {
const container = document.querySelector(`.${styles.images}`);
if (!container) return;
const newColumnCount = window
.getComputedStyle(container)
.getPropertyValue('grid-template-columns')
.split(' ').length;
if (newColumnCount !== columnCount) {
setColumnCount(newColumnCount);
}
};
updateColumns();
updateColumnCount();
window.addEventListener('resize', updateColumnCount);
return () => window.removeEventListener('resize', updateColumnCount);
}, [columnCount, initialImages]);
useEffect(() => {
const imageId = searchParams.get('image');
setFullScreenImageId(imageId);
}, [searchParams]);
useEffect(() => {
if (fullScreenImageId) {
const params = new URLSearchParams(searchParams.toString());
params.set('image', fullScreenImageId);
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
document.body.style.overflow = 'hidden';
} else { } else {
const params = new URLSearchParams(searchParams.toString()); url.searchParams.delete('image');
params.delete('image');
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
document.body.style.overflow = '';
} }
return () => { window.history.replaceState(
document.body.style.overflow = ''; window.history.state,
}; '',
}, [fullScreenImageId, searchParams, navigationRouter]); `${url.pathname}${url.search}${url.hash}`,
);
}, []);
// Read a fullscreen image from the initial URL.
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(searchParams.toString()); const synchronizeWithUrl = () => {
navigationRouter.replace(`?${params.toString()}`, { scroll: false }); const params = new URLSearchParams(window.location.search);
}, [searchParams, navigationRouter]); setFullScreenImageIdState(params.get('image'));
};
synchronizeWithUrl();
window.addEventListener('popstate', synchronizeWithUrl);
return () => {
window.removeEventListener('popstate', synchronizeWithUrl);
};
}, []);
// Prevent the gallery page from scrolling while the modal is open.
useEffect(() => {
if (!fullScreenImageId) {
return;
}
const previousOverflow = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setFullScreenImage(null);
}
};
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [fullScreenImageId, setFullScreenImage]);
const currentImageIndex = fullScreenImageId
? initialImages.findIndex(
(image) => image.id === fullScreenImageId,
)
: -1;
const previousImage =
currentImageIndex > 0
? initialImages[currentImageIndex - 1]
: null;
const nextImage =
currentImageIndex >= 0 &&
currentImageIndex < initialImages.length - 1
? initialImages[currentImageIndex + 1]
: null;
return ( return (
<div className={styles.gallery}> <div className={styles.gallery}>
<div className={styles.images}> <div className={styles.images}>
{images.map((image: ImageMeta) => ( {initialImages.map((image) => {
<div const isHorizontal =
key={uuidv4()} image.aspect_ratio > HORIZONTAL_ASPECT_RATIO;
className={`${styles.imagesContainer} ${
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO const isVertical =
? styles.horizontal image.aspect_ratio < VERTICAL_ASPECT_RATIO;
: image.aspect_ratio < VERTICAL_ASPECT_RATIO
? styles.vertical return (
: '' <div
}`}> key={image.id}
<Image className={`${styles.imagesContainer} ${
width={image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? image.width : 700} isHorizontal
height={image.aspect_ratio < VERTICAL_ASPECT_RATIO ? image.height : 700} ? styles.horizontal
loading="lazy" : isVertical
src={`/api/images/${image.id}`} ? styles.vertical
alt={image.aspect_ratio?.toString()} : ''
onClick={() => setFullScreenImageId(image.id)} }`}
/> >
</div> <Image
))} src={`/api/images/${image.id}`}
alt={`Gallery image ${image.id}`}
width={isHorizontal ? image.width : 700}
height={isVertical ? image.height : 700}
loading="lazy"
onClick={() => setFullScreenImage(image.id)}
/>
</div>
);
})}
</div> </div>
{fullScreenImageId && ( {fullScreenImageId && (
<div className={styles.fullscreenModal} onClick={() => setFullScreenImageId(null)}> <div
className={styles.fullscreenModal}
role="dialog"
aria-modal="true"
aria-label="Fullscreen image"
onClick={() => setFullScreenImage(null)}
>
<Image <Image
src={`/api/images/${fullScreenImageId}`} src={`/api/images/${fullScreenImageId}`}
alt="Full Screen" alt="Fullscreen gallery image"
width={1920} width={1920}
height={1080} height={1080}
style={{ objectFit: 'contain' }} style={{ objectFit: 'contain' }}
onClick={(e) => e.stopPropagation()} onClick={(event) => event.stopPropagation()}
/> />
<button <button
type="button"
className={styles.closeButton} className={styles.closeButton}
onClick={(e) => { aria-label="Close fullscreen image"
e.stopPropagation(); onClick={(event) => {
setFullScreenImageId(null); event.stopPropagation();
}}> setFullScreenImage(null);
}}
>
&times; &times;
</button> </button>
<button {previousImage && (
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`} <button
onClick={(e) => { type="button"
e.stopPropagation(); className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId); aria-label="Previous image"
if (currentIndex > 0) { onClick={(event) => {
setFullScreenImageId(images[currentIndex - 1].id); event.stopPropagation();
} setFullScreenImage(previousImage.id);
}}> }}
&#8249; >
</button> &#8249;
</button>
)}
<button {nextImage && (
className={`${styles.arrowButton} ${styles.arrowButtonRight}`} <button
onClick={(e) => { type="button"
e.stopPropagation(); className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId); aria-label="Next image"
if (currentIndex < images.length - 1) { onClick={(event) => {
setFullScreenImageId(images[currentIndex + 1].id); event.stopPropagation();
} setFullScreenImage(nextImage.id);
}}> }}
&#8250; >
</button> &#8250;
</button>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
+125 -27
View File
@@ -1,36 +1,134 @@
'use client';
import { useId, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import styles from '@/styles/Tags.module.scss'; import styles from '@/styles/Tags.module.scss';
import { redirect } from 'next/navigation';
interface TagsProps {
activeTag: string;
tags: string[];
redirectUrlWithPlaceholder?: string;
}
/**
* @param {string} redirectUrlWithPlaceholder Use `${tag}` as a placeholder in the redirect URL. e.g. `/gallery/${tag}/1`
*/
export default function Tags({ export default function Tags({
activeTag, activeTag,
tags, tags,
redirectUrlWithPlaceholder = '/gallery/${tag}/1', redirectUrlWithPlaceholder = '/gallery/${tag}/1',
}: { }: TagsProps) {
activeTag: string; const router = useRouter();
tags: string[]; const triggerRef = useRef<HTMLButtonElement>(null);
redirectUrlWithPlaceholder?: string; const optionsId = useId();
}) {
const [isOpen, setIsOpen] = useState(false);
const options = Array.from(
new Set([
'all',
...tags.filter((tag) => tag.length > 0 && tag !== 'all'),
]),
);
const selectTag = (tag: string) => {
setIsOpen(false);
// Do not navigate to the page that is already active.
if (tag === activeTag) {
triggerRef.current?.focus();
return;
}
const url = redirectUrlWithPlaceholder.replace(
'${tag}',
encodeURIComponent(tag),
);
router.push(url);
};
const closeDropdown = () => {
setIsOpen(false);
triggerRef.current?.focus();
};
return ( return (
<div className={styles.tags}> <div
<span className={styles.tagFilter}
key="all" onBlur={(event) => {
className={`${styles.tag} ${activeTag === 'all' ? styles.tagActive : ''}`} const nextFocusedElement = event.relatedTarget as Node | null;
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', 'all'))}>
All if (!event.currentTarget.contains(nextFocusedElement)) {
</span> setIsOpen(false);
{tags.map((tag, index) => { }
return ( }}
<span onKeyDown={(event) => {
key={index} if (event.key === 'Escape' && isOpen) {
className={`${styles.tag} ${activeTag === tag ? styles.tagActive : ''}`} event.preventDefault();
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', tag))}> closeDropdown();
{tag} }
</span> }}
); >
})} <button
ref={triggerRef}
type="button"
className={`${styles.trigger} ${
isOpen ? styles.triggerOpen : ''
}`}
aria-haspopup="listbox"
aria-controls={optionsId}
aria-expanded={isOpen}
onClick={() => setIsOpen((current) => !current)}
>
<span className={styles.triggerLabel}>
{activeTag === 'all' ? 'All images' : activeTag}
</span>
<svg
className={styles.chevron}
viewBox="0 0 20 20"
aria-hidden="true"
>
<path d="m5.5 7.5 4.5 4.5 4.5-4.5" />
</svg>
</button>
{isOpen && (
<div
id={optionsId}
className={styles.options}
role="listbox"
aria-label="Filter images by tag"
>
{options.map((tag) => {
const isActive = tag === activeTag;
const label = tag === 'all' ? 'All images' : tag;
return (
<button
key={tag}
type="button"
role="option"
aria-selected={isActive}
className={`${styles.option} ${
isActive ? styles.optionActive : ''
}`}
onClick={() => selectTag(tag)}
>
<span>{label}</span>
{isActive && (
<svg
className={styles.check}
viewBox="0 0 20 20"
aria-hidden="true"
>
<path d="m4.5 10 3.3 3.3 7.7-7.6" />
</svg>
)}
</button>
);
})}
</div>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -25,8 +25,8 @@
} }
&:disabled { &:disabled {
background-color: #0f0f0f;
cursor: not-allowed; cursor: not-allowed;
color: rgb(138, 137, 137);
&:hover { &:hover {
border-bottom-color: transparent; border-bottom-color: transparent;
+165 -16
View File
@@ -1,25 +1,174 @@
.tags { .tagFilter {
display: flex; position: relative;
justify-content: start; width: min(220px, 100%);
align-items: start; font-size: 0.9rem;
gap: 5px 20px; z-index: 20;
flex-wrap: wrap;
flex: 1;
} }
.tag { .trigger {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 11px 8px 13px;
color: rgba(255, 255, 255, 0.88);
background: rgba(35, 38, 42, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
font: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
padding: 0 5px;
transition: all 30ms ease-in-out; box-shadow:
border-bottom: 2px solid transparent; 0 4px 14px rgba(0, 0, 0, 0.12),
font-size: 12pt; inset 0 1px rgba(255, 255, 255, 0.025);
transition:
border-color 70ms ease,
background-color 100ms ease,
box-shadow 100ms ease;
&:hover { &:hover {
border-bottom-color: var(--color-accent); background: rgba(41, 44, 49, 0.96);
border-color: rgba(255, 255, 255, 0.16);
} }
&Active { &:focus-visible {
border-bottom-color: var(--color-accent); outline: none;
font-weight: bold; border-color: color-mix(in srgb, var(--color-accent) 75%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 14%, transparent);
}
}
.triggerOpen {
border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
.chevron {
transform: rotate(180deg);
}
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
width: 17px;
height: 17px;
flex-shrink: 0;
fill: none;
stroke: rgba(255, 255, 255, 0.55);
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 100ms ease;
}
.options {
position: absolute;
top: calc(100% + 7px);
left: 0;
right: 0;
max-height: 260px;
overflow-y: auto;
padding: 5px;
background: rgba(29, 31, 35, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow:
0 16px 38px rgba(0, 0, 0, 0.35),
inset 0 1px rgba(255, 255, 255, 0.035);
backdrop-filter: blur(14px);
animation: openOptions 130ms ease-out;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.16) transparent;
}
.option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
padding: 8px 10px;
color: rgba(255, 255, 255, 0.72);
background: transparent;
border: 0;
border-radius: 6px;
font: inherit;
text-align: left;
cursor: pointer;
transition:
color 70ms ease,
background-color 70ms ease;
&:hover,
&:focus-visible {
color: rgba(255, 255, 255, 0.96);
background: rgba(255, 255, 255, 0.065);
outline: none;
}
}
.optionActive {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
&:hover,
&:focus-visible {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 13%, transparent);
}
}
.check {
width: 16px;
height: 16px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
@keyframes openOptions {
from {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.options {
animation: none;
}
.trigger,
.chevron,
.option {
transition: none;
} }
} }