New style & faster user art tab

This commit is contained in:
Kalle
2026-09-19 14:09:18 +03:00
parent 487ed99b4c
commit 8895afc3ce
5 changed files with 264 additions and 80 deletions

View File

@@ -18,12 +18,12 @@
margin: auto;
outline: none;
animation: zoom-in-95 300ms ease-out;
}
&::backdrop {
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 300ms ease-out;
}
.blurredBackdrop::backdrop {
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 300ms ease-out;
}
@keyframes fade-in {

View File

@@ -13,6 +13,8 @@ import styles from "./Dialog.module.css";
interface DialogElementProps {
id?: string;
className?: string;
/** The standard dialog backdrop: dimmed and blurred page behind. */
blurredBackdrop?: boolean;
isDismissable?: boolean;
onClose?: () => void;
"aria-label"?: string;
@@ -57,6 +59,7 @@ export function SendouModal({ ref, ...rest }: DialogElementProps) {
function DialogElement({
id,
className,
blurredBackdrop,
isDismissable,
onClose,
"aria-label": ariaLabel,
@@ -68,7 +71,9 @@ function DialogElement({
<dialog
ref={ref}
id={id}
className={className}
className={clsx(className, {
[styles.blurredBackdrop]: blurredBackdrop,
})}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
tabIndex={-1}
@@ -307,6 +312,7 @@ function dialogElementProps(
className: clsx(className, styles.modal, "scrollbar", {
[styles.fullScreenModal]: isFullScreen,
}),
blurredBackdrop: true,
isDismissable,
onClose,
"aria-label": ariaLabel,

View File

@@ -7,38 +7,141 @@
}
}
.lightbox {
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #fff;
outline: none;
overflow: hidden;
}
.lightboxBody {
width: 100%;
height: 100%;
display: grid;
place-items: center;
padding: var(--s-4);
cursor: zoom-out;
}
.lightboxFigure {
--lightbox-max-width: calc(100vw - 2 * var(--s-4));
--lightbox-max-height: calc(100dvh - 2 * var(--s-4));
position: relative;
margin: 0;
max-width: var(--lightbox-max-width);
max-height: var(--lightbox-max-height);
border-radius: var(--radius-box);
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: lightbox-zoom-in 200ms ease-out;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.lightboxFigureSized {
aspect-ratio: var(--aspect-ratio);
width: min(
var(--lightbox-max-width),
calc(var(--lightbox-max-height) * var(--aspect-ratio))
);
}
.lightboxPlaceholder {
display: block;
width: 100%;
height: auto;
max-width: var(--lightbox-max-width);
max-height: var(--lightbox-max-height);
object-fit: contain;
}
.lightboxImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 250ms ease-out;
}
.lightboxImgSettled {
opacity: 1;
}
.lightboxInfo {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
padding: var(--s-8) var(--s-4) var(--s-3);
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
text-align: center;
transition: opacity 200ms ease-out;
@media (hover: hover) {
opacity: 0;
.lightboxFigure:hover &,
.lightboxFigure:focus-within & {
opacity: 1;
}
}
}
.lightboxInfoHidden {
opacity: 0;
}
.lightboxDate {
font-size: var(--font-2xs);
color: rgba(255, 255, 255, 0.7);
}
.lightboxDescription {
font-size: var(--font-sm);
text-wrap: balance;
}
.tagsContainer {
display: flex;
gap: var(--s-1) var(--s-2);
justify-content: center;
flex-wrap: wrap;
}
.dialogTag {
background-color: #fff;
border-radius: var(--radius-box);
color: #000;
font-size: var(--font-2xs);
padding-inline: var(--s-1);
margin-block: var(--s-1) var(--s-0-5);
}
.dialogTagUser {
background-color: var(--color-accent);
}
.dialogDescription {
font-size: var(--font-sm);
text-align: center;
color: #fff;
}
.dialogImg {
max-width: 100%;
max-height: 75vh;
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.tagsContainer {
display: flex;
gap: var(--s-0-5) var(--s-2);
justify-content: center;
flex-wrap: wrap;
margin-block-start: var(--s-0-5);
@keyframes lightbox-zoom-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -1,11 +1,11 @@
import clsx from "clsx";
import { SquarePen, Trash, Unlink, X } from "lucide-react";
import { SquarePen, Trash, Unlink } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouModal } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Pagination } from "~/components/Pagination";
import { artPage, newArtPage, userArtPage } from "~/features/art/art-urls";
@@ -24,6 +24,8 @@ import type { ListedArt } from "../art-types";
import { previewUrl } from "../art-utils";
import styles from "./ArtGrid.module.css";
const preloadedImageUrls = new Set<string>();
export function ArtGrid({
arts,
enablePreview = false,
@@ -84,68 +86,99 @@ export function ArtGrid({
}
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [infoVisible, setInfoVisible] = React.useState(true);
const [imageSettled, imageRef] = useImageSettled();
const [aspectRatio, placeholderRef] = useImageAspectRatio();
const { formatter } = useDateTimeFormat({
year: "numeric",
month: "numeric",
day: "numeric",
});
const dateText = formatter.format(databaseTimestampToDate(art.createdAt));
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest("a")) return;
if (target.closest("figure") && !deviceCanHover()) {
setInfoVisible((visible) => !visible);
return;
}
dialogRef.current?.close();
};
return (
<SendouDialog
heading={formatter.format(databaseTimestampToDate(art.createdAt)) ?? ""}
<SendouModal
ref={dialogRef}
className={styles.lightbox}
blurredBackdrop
onClose={close}
isFullScreen
aria-label={art.description || dateText}
>
<img
alt=""
src={art.url}
loading="lazy"
className={styles.dialogImg}
ref={imageRef}
/>
{art.tags || art.linkedUsers ? (
<div
className={clsx(styles.tagsContainer, { invisible: !imageSettled })}
>
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className={clsx(styles.dialogTag, styles.dialogTagUser)}
>
{user.username}
</Link>
))}
{art.tags?.map((tag) => (
<Link
to={artPage(tag.name)}
key={tag.id}
className={styles.dialogTag}
>
#{tag.name}
</Link>
))}
</div>
) : null}
{art.description ? (
<div
className={clsx(styles.dialogDescription, {
invisible: !imageSettled,
{/* biome-ignore lint/a11y/noStaticElementInteractions: click-anywhere to close, Escape is handled by the dialog */}
<div className={styles.lightboxBody} onClick={handleClick}>
<figure
className={clsx(styles.lightboxFigure, {
[styles.lightboxFigureSized]: aspectRatio,
})}
style={
aspectRatio
? ({ "--aspect-ratio": aspectRatio } as React.CSSProperties)
: undefined
}
>
{art.description}
</div>
) : null}
<SendouButton
variant="destructive"
className="mx-auto mt-6"
onClick={close}
icon={<X />}
>
Close
</SendouButton>
</SendouDialog>
<img
alt=""
src={previewUrl(art.url)}
className={styles.lightboxPlaceholder}
ref={placeholderRef}
/>
<img
alt=""
src={art.url}
className={clsx(styles.lightboxImg, {
[styles.lightboxImgSettled]: imageSettled,
})}
ref={imageRef}
/>
<figcaption
className={clsx(styles.lightboxInfo, {
[styles.lightboxInfoHidden]: !infoVisible,
})}
>
<time className={styles.lightboxDate}>{dateText}</time>
{art.description ? (
<div className={styles.lightboxDescription}>
{art.description}
</div>
) : null}
{art.tags || art.linkedUsers ? (
<div className={styles.tagsContainer}>
{art.linkedUsers?.map((user) => (
<Link
to={userPage(user)}
key={user.discordId}
className={clsx(styles.dialogTag, styles.dialogTagUser)}
>
{user.username}
</Link>
))}
{art.tags?.map((tag) => (
<Link
to={artPage(tag.name)}
key={tag.id}
className={styles.dialogTag}
>
#{tag.name}
</Link>
))}
</div>
) : null}
</figcaption>
</figure>
</div>
</SendouModal>
);
}
@@ -173,6 +206,7 @@ function ImagePreview({
src={previewUrl(art.url)}
loading="lazy"
onClick={onClick}
onPointerEnter={enablePreview ? () => preloadImage(art.url) : undefined}
ref={imageRef}
className={enablePreview ? styles.thumbnail : undefined}
data-testid="art-image"
@@ -329,3 +363,39 @@ function useImageSettled() {
return [imageSettled, imageRef] as const;
}
/** Aspect ratio of the image once it has loaded, and the ref to give it. */
function useImageAspectRatio() {
const [aspectRatio, setAspectRatio] = React.useState<number | null>(null);
const imageRef = (image: HTMLImageElement | null) => {
if (!image) return;
const measure = () => {
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
setAspectRatio(image.naturalWidth / image.naturalHeight);
}
};
if (image.complete) {
measure();
return;
}
image.addEventListener("load", measure);
return () => image.removeEventListener("load", measure);
};
return [aspectRatio, imageRef] as const;
}
/** Touch devices have no hover to reveal the lightbox info with, so a tap on the image toggles it instead. */
function deviceCanHover() {
return window.matchMedia("(hover: hover)").matches;
}
/** Warms the browser cache so the full-size image is ready by the time the lightbox opens. */
function preloadImage(url: string) {
if (preloadedImageUrls.has(url)) return;
preloadedImageUrls.add(url);
new Image().src = url;
}

View File

@@ -0,0 +1,5 @@
---
navItem: art
type: feature
---
User art page big art view has been redesigned and speed improved.