mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-20 21:59:53 -05:00
feat: enable preview image slideshow on model item hover
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user