From 7daf2b17833fe24a2022572691b6eeba651eb8b1 Mon Sep 17 00:00:00 2001 From: Janis Date: Mon, 31 Aug 2026 05:35:53 +0200 Subject: [PATCH] Add minor UX improvements --- src/app/gallery/[tag]/[page]/page.tsx | 103 +++++---- src/app/gallery/[tag]/page.tsx | 12 +- src/app/gallery/page.tsx | 2 +- src/app/index.scss | 36 +++- src/app/styles.module.scss | 1 + src/components/Gallery.tsx | 299 +++++++++++++------------- src/components/Tags.tsx | 152 ++++++++++--- src/styles/Paginator.module.scss | 2 +- src/styles/Tags.module.scss | 181 ++++++++++++++-- 9 files changed, 547 insertions(+), 241 deletions(-) diff --git a/src/app/gallery/[tag]/[page]/page.tsx b/src/app/gallery/[tag]/[page]/page.tsx index 46d5f2a..3ddcdec 100644 --- a/src/app/gallery/[tag]/[page]/page.tsx +++ b/src/app/gallery/[tag]/[page]/page.tsx @@ -4,52 +4,79 @@ import Topbar from '@/components/Topbar'; import { ImagesResponse, TagsResponse } from '@/interfaces/api'; import { PaginatorPosition } from '@/interfaces/paginator'; import { redirect } from 'next/navigation'; -import { Suspense } from 'react'; -export default async function GalleryPage({ params }: { params: Promise<{ page: number; tag: string }> }) { - const { tag, page } = await params; +interface GalleryPageProps { + params: Promise<{ + tag: string; + page: string; + }>; +} - const fetchImages = async () => { - const apiUrl = new URL( - `/api/images?page=${page}&tag=${tag === 'all' ? '' : tag}`, - 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}`); - } +export default async function GalleryPage({ + params, +}: GalleryPageProps) { + const { tag, page: pageParam } = await params; + const page = Number.parseInt(pageParam, 10); - return (await response.json()) as ImagesResponse; - }; - - 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 (!Number.isInteger(page) || page < 1) { + redirect(`/gallery/${encodeURIComponent(tag)}/1`); } - if (page <= 0) { - redirect(`/gallery/${tag}/1`); + const siteUrl = + 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 ( <> - Loading gallery...}> - - - - + + + + + ); -} +} \ No newline at end of file diff --git a/src/app/gallery/[tag]/page.tsx b/src/app/gallery/[tag]/page.tsx index 837b2f3..7b011a8 100644 --- a/src/app/gallery/[tag]/page.tsx +++ b/src/app/gallery/[tag]/page.tsx @@ -1,5 +1,13 @@ import { redirect } from 'next/navigation'; -export default async function Page({ params }: { params: Promise<{ page: number; tag: string }> }) { - redirect(`gallery/${(await params).tag}/1`); +interface TagPageProps { + params: Promise<{ + tag: string; + }>; } + +export default async function TagPage({ params }: TagPageProps) { + const { tag } = await params; + + redirect(`/gallery/${encodeURIComponent(tag)}/1`); +} \ No newline at end of file diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx index 285d555..826612c 100644 --- a/src/app/gallery/page.tsx +++ b/src/app/gallery/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; export default async function Page() { - redirect(`gallery/all/1`); + redirect(`/gallery/all/1`); } diff --git a/src/app/index.scss b/src/app/index.scss index 012143d..94bd6f3 100644 --- a/src/app/index.scss +++ b/src/app/index.scss @@ -1,6 +1,8 @@ * { --header-height: 50px; --color-accent: #3cdbc0; + --color-background: #0d1113; + --color-surface: #151b1d; } :root { @@ -8,9 +10,9 @@ line-height: 1.5; font-weight: 400; - color-scheme: light dark; + color-scheme: dark; color: rgba(255, 255, 255, 0.87); - background-color: rgb(27, 27, 27); + background-color: var(--color-background); font-synthesis: none; text-rendering: optimizeLegibility; @@ -18,22 +20,34 @@ -moz-osx-font-smoothing: grayscale; } -body, -#root { +html { + min-height: 100%; + background-color: var(--color-background); +} + +body { margin: 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; flex-direction: column; - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - letter-spacing: 1px; min-height: 100vh; - scrollbar-gutter: stable; - background: linear-gradient(-120deg, rgba(0, 0, 0, 0), #06565a49); } a { - text-decoration: none; color: inherit; + text-decoration: none; } diff --git a/src/app/styles.module.scss b/src/app/styles.module.scss index f9c35a0..278882a 100644 --- a/src/app/styles.module.scss +++ b/src/app/styles.module.scss @@ -4,6 +4,7 @@ padding: 0 40px; overflow: hidden; background-image: url('/landing-page.jpg'); + min-height: calc(100vh - var(--header-height)); background-size: cover; @media (max-width: 1200px) { diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 370e95f..7a79a98 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -3,185 +3,194 @@ import { ImageMeta } from '@/interfaces/image'; import styles from '@/styles/Gallery.module.scss'; import Image from 'next/image'; -import { useRouter as useNavigationRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { v4 as uuidv4 } from 'uuid'; +import { useCallback, useEffect, useState } from 'react'; interface GalleryProps { initialImages: ImageMeta[]; } -export default function Gallery({ initialImages }: GalleryProps) { - const HORIZONTAL_ASPECT_RATIO = 1.8; - const VERTICAL_ASPECT_RATIO = 0.8; +const HORIZONTAL_ASPECT_RATIO = 1.8; +const VERTICAL_ASPECT_RATIO = 0.8; - const navigationRouter = useNavigationRouter(); - const searchParams = useSearchParams(); - const [images, setImages] = useState([]); - const [columnCount, setColumnCount] = useState(0); - const [fullScreenImageId, setFullScreenImageId] = useState(null); +export default function Gallery({ + initialImages, +}: GalleryProps) { + const [fullScreenImageId, setFullScreenImageIdState] = useState< + string | null + >(null); - useEffect(() => { - const updateColumns = () => { - const imageStack = [...initialImages]; - const newImages: ImageMeta[] = []; + /* + * Update the URL without causing a Next.js navigation. + * + * 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) { - let usedColumnsInRow = 0; - let usedRowsInColumn = 0; - let index = 0; + const url = new URL(window.location.href); - for (const image of imageStack) { - usedColumnsInRow += image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1; - 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'; + if (imageId) { + url.searchParams.set('image', imageId); } else { - const params = new URLSearchParams(searchParams.toString()); - params.delete('image'); - navigationRouter.replace(`?${params.toString()}`, { scroll: false }); - - document.body.style.overflow = ''; + url.searchParams.delete('image'); } - return () => { - document.body.style.overflow = ''; - }; - }, [fullScreenImageId, searchParams, navigationRouter]); + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); + }, []); + // Read a fullscreen image from the initial URL. useEffect(() => { - const params = new URLSearchParams(searchParams.toString()); - navigationRouter.replace(`?${params.toString()}`, { scroll: false }); - }, [searchParams, navigationRouter]); + const synchronizeWithUrl = () => { + const params = new URLSearchParams(window.location.search); + 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 (
- {images.map((image: ImageMeta) => ( -
HORIZONTAL_ASPECT_RATIO - ? styles.horizontal - : image.aspect_ratio < VERTICAL_ASPECT_RATIO - ? styles.vertical - : '' - }`}> - HORIZONTAL_ASPECT_RATIO ? image.width : 700} - height={image.aspect_ratio < VERTICAL_ASPECT_RATIO ? image.height : 700} - loading="lazy" - src={`/api/images/${image.id}`} - alt={image.aspect_ratio?.toString()} - onClick={() => setFullScreenImageId(image.id)} - /> -
- ))} + {initialImages.map((image) => { + const isHorizontal = + image.aspect_ratio > HORIZONTAL_ASPECT_RATIO; + + const isVertical = + image.aspect_ratio < VERTICAL_ASPECT_RATIO; + + return ( +
+ {`Gallery setFullScreenImage(image.id)} + /> +
+ ); + })}
{fullScreenImageId && ( -
setFullScreenImageId(null)}> +
setFullScreenImage(null)} + > Full Screen e.stopPropagation()} + onClick={(event) => event.stopPropagation()} /> + - + {previousImage && ( + + )} - + {nextImage && ( + + )}
)}
); -} +} \ No newline at end of file diff --git a/src/components/Tags.tsx b/src/components/Tags.tsx index 6788ce7..393b7be 100644 --- a/src/components/Tags.tsx +++ b/src/components/Tags.tsx @@ -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 { 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({ activeTag, tags, redirectUrlWithPlaceholder = '/gallery/${tag}/1', -}: { - activeTag: string; - tags: string[]; - redirectUrlWithPlaceholder?: string; -}) { +}: TagsProps) { + const router = useRouter(); + const triggerRef = useRef(null); + 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 ( -
- redirect(redirectUrlWithPlaceholder.replace('${tag}', 'all'))}> - All - - {tags.map((tag, index) => { - return ( - redirect(redirectUrlWithPlaceholder.replace('${tag}', tag))}> - {tag} - - ); - })} +
{ + const nextFocusedElement = event.relatedTarget as Node | null; + + if (!event.currentTarget.contains(nextFocusedElement)) { + setIsOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape' && isOpen) { + event.preventDefault(); + closeDropdown(); + } + }} + > + + + {isOpen && ( +
+ {options.map((tag) => { + const isActive = tag === activeTag; + const label = tag === 'all' ? 'All images' : tag; + + return ( + + ); + })} +
+ )}
); -} +} \ No newline at end of file diff --git a/src/styles/Paginator.module.scss b/src/styles/Paginator.module.scss index 7e99b95..6ab4f42 100644 --- a/src/styles/Paginator.module.scss +++ b/src/styles/Paginator.module.scss @@ -25,8 +25,8 @@ } &:disabled { - background-color: #0f0f0f; cursor: not-allowed; + color: rgb(138, 137, 137); &:hover { border-bottom-color: transparent; diff --git a/src/styles/Tags.module.scss b/src/styles/Tags.module.scss index bb5a161..601ded7 100644 --- a/src/styles/Tags.module.scss +++ b/src/styles/Tags.module.scss @@ -1,25 +1,174 @@ -.tags { - display: flex; - justify-content: start; - align-items: start; - gap: 5px 20px; - flex-wrap: wrap; - flex: 1; +.tagFilter { + position: relative; + width: min(220px, 100%); + font-size: 0.9rem; + z-index: 20; } -.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; - padding: 0 5px; - transition: all 30ms ease-in-out; - border-bottom: 2px solid transparent; - font-size: 12pt; + + box-shadow: + 0 4px 14px rgba(0, 0, 0, 0.12), + inset 0 1px rgba(255, 255, 255, 0.025); + + transition: + border-color 70ms ease, + background-color 100ms ease, + box-shadow 100ms ease; &:hover { - border-bottom-color: var(--color-accent); + background: rgba(41, 44, 49, 0.96); + border-color: rgba(255, 255, 255, 0.16); } - &Active { - border-bottom-color: var(--color-accent); - font-weight: bold; + &:focus-visible { + outline: none; + 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; } }