This commit is contained in:
@@ -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',
|
||||
export default async function GalleryPage({
|
||||
params,
|
||||
}: GalleryPageProps) {
|
||||
const { tag, page: pageParam } = await params;
|
||||
const page = Number.parseInt(pageParam, 10);
|
||||
|
||||
if (!Number.isInteger(page) || page < 1) {
|
||||
redirect(`/gallery/${encodeURIComponent(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}`,
|
||||
);
|
||||
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;
|
||||
};
|
||||
|
||||
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}`);
|
||||
if (!tagsResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to load tags: ${tagsResponse.status} ${tagsResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as TagsResponse;
|
||||
};
|
||||
const images = (await imagesResponse.json()) as ImagesResponse;
|
||||
const tags = (await tagsResponse.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) {
|
||||
redirect(`/gallery/${tag}/1`);
|
||||
if (images.totalPages > 0 && page > images.totalPages) {
|
||||
redirect(
|
||||
`/gallery/${encodeURIComponent(tag)}/${images.totalPages}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading gallery...</div>}>
|
||||
<Topbar activeTag={tag} tags={tags} page={page} totalPages={images.totalPages} />
|
||||
<Topbar
|
||||
activeTag={tag}
|
||||
tags={tags}
|
||||
page={page}
|
||||
totalPages={images.totalPages}
|
||||
/>
|
||||
|
||||
<Gallery initialImages={images.images} />
|
||||
<Paginator page={page} totalPages={images.totalPages} position={PaginatorPosition.BOTTOM} />
|
||||
</Suspense>
|
||||
|
||||
<Paginator
|
||||
page={page}
|
||||
totalPages={images.totalPages}
|
||||
position={PaginatorPosition.BOTTOM}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
redirect(`gallery/all/1`);
|
||||
redirect(`/gallery/all/1`);
|
||||
}
|
||||
|
||||
+25
-11
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+132
-123
@@ -3,183 +3,192 @@
|
||||
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 navigationRouter = useNavigationRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [images, setImages] = useState<ImageMeta[]>([]);
|
||||
const [columnCount, setColumnCount] = useState(0);
|
||||
const [fullScreenImageId, setFullScreenImageId] = useState<string | null>(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');
|
||||
}
|
||||
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${url.pathname}${url.search}${url.hash}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Read a fullscreen image from the initial URL.
|
||||
useEffect(() => {
|
||||
const synchronizeWithUrl = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setFullScreenImageIdState(params.get('image'));
|
||||
};
|
||||
|
||||
synchronizeWithUrl();
|
||||
|
||||
window.addEventListener('popstate', synchronizeWithUrl);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('popstate', synchronizeWithUrl);
|
||||
};
|
||||
}, [fullScreenImageId, searchParams, navigationRouter]);
|
||||
}, []);
|
||||
|
||||
// Prevent the gallery page from scrolling while the modal is open.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
|
||||
}, [searchParams, navigationRouter]);
|
||||
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 (
|
||||
<div className={styles.gallery}>
|
||||
<div className={styles.images}>
|
||||
{images.map((image: ImageMeta) => (
|
||||
{initialImages.map((image) => {
|
||||
const isHorizontal =
|
||||
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO;
|
||||
|
||||
const isVertical =
|
||||
image.aspect_ratio < VERTICAL_ASPECT_RATIO;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uuidv4()}
|
||||
key={image.id}
|
||||
className={`${styles.imagesContainer} ${
|
||||
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO
|
||||
isHorizontal
|
||||
? styles.horizontal
|
||||
: image.aspect_ratio < VERTICAL_ASPECT_RATIO
|
||||
: isVertical
|
||||
? styles.vertical
|
||||
: ''
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
width={image.aspect_ratio > 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)}
|
||||
alt={`Gallery image ${image.id}`}
|
||||
width={isHorizontal ? image.width : 700}
|
||||
height={isVertical ? image.height : 700}
|
||||
loading="lazy"
|
||||
onClick={() => setFullScreenImage(image.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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
|
||||
src={`/api/images/${fullScreenImageId}`}
|
||||
alt="Full Screen"
|
||||
alt="Fullscreen gallery image"
|
||||
width={1920}
|
||||
height={1080}
|
||||
style={{ objectFit: 'contain' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFullScreenImageId(null);
|
||||
}}>
|
||||
aria-label="Close fullscreen image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(null);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{previousImage && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId);
|
||||
if (currentIndex > 0) {
|
||||
setFullScreenImageId(images[currentIndex - 1].id);
|
||||
}
|
||||
}}>
|
||||
aria-label="Previous image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(previousImage.id);
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{nextImage && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const currentIndex = images.findIndex((image) => image.id === fullScreenImageId);
|
||||
if (currentIndex < images.length - 1) {
|
||||
setFullScreenImageId(images[currentIndex + 1].id);
|
||||
}
|
||||
}}>
|
||||
aria-label="Next image"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setFullScreenImage(nextImage.id);
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+120
-22
@@ -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<HTMLButtonElement>(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 (
|
||||
<div className={styles.tags}>
|
||||
<span
|
||||
key="all"
|
||||
className={`${styles.tag} ${activeTag === 'all' ? styles.tagActive : ''}`}
|
||||
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', 'all'))}>
|
||||
All
|
||||
<div
|
||||
className={styles.tagFilter}
|
||||
onBlur={(event) => {
|
||||
const nextFocusedElement = event.relatedTarget as Node | null;
|
||||
|
||||
if (!event.currentTarget.contains(nextFocusedElement)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{tags.map((tag, index) => {
|
||||
|
||||
<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 (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.tag} ${activeTag === tag ? styles.tagActive : ''}`}
|
||||
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', tag))}>
|
||||
{tag}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,8 @@
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #0f0f0f;
|
||||
cursor: not-allowed;
|
||||
color: rgb(138, 137, 137);
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: transparent;
|
||||
|
||||
+165
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user