diff --git a/frontend/src/components/media/HoverSlideshow.tsx b/frontend/src/components/media/HoverSlideshow.tsx new file mode 100644 index 0000000..f900346 --- /dev/null +++ b/frontend/src/components/media/HoverSlideshow.tsx @@ -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(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 ( + + {images.map((src, idx) => ( + + ))} + + ); +} diff --git a/frontend/src/pages/ModelsPage/ModelCard.tsx b/frontend/src/pages/ModelsPage/ModelCard.tsx index 1490cb0..0cbe88e 100644 --- a/frontend/src/pages/ModelsPage/ModelCard.tsx +++ b/frontend/src/pages/ModelsPage/ModelCard.tsx @@ -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 ( 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 }, }} > - + {/* 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. */} + {renderPreviewContent(item, "card", theme, t, previewMode)} + {hovered && slideshowImages.length > 0 && ( + + )}