mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
New style & faster user art tab
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
changelog/2026-09-19-art-lightbox.md
Normal file
5
changelog/2026-09-19-art-lightbox.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: art
|
||||
type: feature
|
||||
---
|
||||
User art page big art view has been redesigned and speed improved.
|
||||
Reference in New Issue
Block a user