feat: enable preview image slideshow on model item hover

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 21:10:12 +02:00
1 parent 8f1ca1fc4a
commit 63133ebd15
2 files changed
+76 -2

No files matched your search

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import Box from "@mui/material/Box";
// First change comes a little sooner than the rest, so hovering feels responsive without a
// quick pass of the pointer over a card flashing it.
const FIRST_SLIDE_DELAY_MS = 500;
const SLIDE_INTERVAL_MS = 1400;
const FADE_MS = 350;
type Props = {
/** Image URLs to cycle through, in order. */
images: string[];
alt?: string;
};
/** Overlay that fades through `images` on top of whatever is rendered beneath it (a card's
* default thumbnail). Mount it only while it should run -- e.g. while a card is hovered: images
* load on mount, and unmounting stops the timer and reveals the default beneath instantly. The
* parent must be `position: relative`. */
export default function HoverSlideshow({ images, alt }: Props) {
// null until the first tick, so every slide starts hidden and fades in (the default thumbnail
// stays visible underneath until then).
const [active, setActive] = useState<number | null>(null);
useEffect(() => {
if (images.length === 0) return;
let timer = window.setTimeout(function tick() {
setActive(current => (current === null ? 0 : (current + 1) % images.length));
timer = window.setTimeout(tick, SLIDE_INTERVAL_MS);
}, FIRST_SLIDE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [images.length]);
return (
<Box sx={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
{images.map((src, idx) => (
<Box
key={src}
component="img"
src={src}
alt={idx === active ? alt ?? "" : ""}
aria-hidden={idx !== active}
// The incoming slide fades in on top while the outgoing one stays fully opaque beneath
// it, only dropping out (instantly, via a delayed 0ms transition) once the fade is done
// -- fading both at once would let the default thumbnail show through mid-crossfade.
sx={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: idx === active ? 1 : 0,
opacity: idx === active ? 1 : 0,
transition: idx === active ? `opacity ${FADE_MS}ms ease` : `opacity 0ms linear ${FADE_MS}ms`,
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
/>
))}
</Box>
);
}
+15 -2
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
@@ -9,7 +10,7 @@ import Tooltip from "@mui/material/Tooltip";
import { useTheme } from "@mui/material/styles";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import type { Print } from "../../api/prints";
import { type Print, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
@@ -17,6 +18,7 @@ import { printProviderInfo } from "../../constants/importProviders";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
import HoverSlideshow from "../../components/media/HoverSlideshow";
import RollingNumber from "../../components/RollingNumber";
import { useFavoriteToggle } from "../../hooks/useFavoriteToggle";
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
@@ -91,11 +93,17 @@ export default function ModelCard({
const authorName = author?.name || author?.handle || item.creator || (showViewerAsAuthor ? viewer!.display_name : null);
const authorAvatarUrl = author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
const providerInfo = printProviderInfo(item.source_provider);
// Hover slideshow through the gallery -- only worth running with more than one image, and
// only mounted while hovered so it neither loads the images nor ticks for idle cards.
const [hovered, setHovered] = useState(false);
const slideshowImages = item.preview_images.length > 1 ? item.preview_images.map(img => printsApi.fileUrl(img.url)) : [];
return (
<Paper
variant="outlined"
onClick={() => navigate(`/models/${item.id}`)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
position: "relative",
cursor: "pointer",
@@ -113,8 +121,13 @@ export default function ModelCard({
"&:hover .model-card-actions": { opacity: 1 },
}}
>
<Box sx={{ width: "100%", aspectRatio: "4 / 3" }}>
{/* zIndex 0 makes this its own stacking context, so the slideshow's layered slides stay
under the provider badge and hover actions rendered after it. */}
<Box sx={{ position: "relative", zIndex: 0, width: "100%", aspectRatio: "4 / 3" }}>
{renderPreviewContent(item, "card", theme, t, previewMode)}
{hovered && slideshowImages.length > 0 && (
<HoverSlideshow images={slideshowImages} alt={item.title || item.name} />
)}
</Box>
<Tooltip