From 0a0dfd5f40bab7732cfeda1eb7e0925369654eb8 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Fri, 11 Sep 2026 15:23:27 +0200 Subject: [PATCH] feat: new favourites toggler --- frontend/src/components/StarToggle.tsx | 168 ++++++++++++++++++ .../pages/ModelDetailPage/FavoriteButton.tsx | 17 +- frontend/src/pages/ModelsPage/ModelCard.tsx | 17 +- 3 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/StarToggle.tsx diff --git a/frontend/src/components/StarToggle.tsx b/frontend/src/components/StarToggle.tsx new file mode 100644 index 0000000..f164864 --- /dev/null +++ b/frontend/src/components/StarToggle.tsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from "react"; +import { keyframes } from "@emotion/react"; +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import StarIcon from "@mui/icons-material/Star"; +import type { SxProps, Theme } from "@mui/material/styles"; + +// Ported from https://codepen.io/matthewbolanos/pen/yYXQZp (the star favourite-toggle burst +// animation), swapping Font Awesome + jQuery for an MUI icon + a React-driven replay. Every +// pixel value below is copied verbatim from that pen: the ring and icon-pop are in `em` +// (cascades from fontSize, so they scale correctly at our much smaller icon size for free), and +// the sparkle circle keeps the pen's raw viewBox units (r=24, dasharray "1 29", stroke-width +// 0->20->0) inside its own viewBox -- SVG scales those with the box's own rendered size, so +// shrinking the box to fit a 20px icon preserves the pen's exact proportions without retuning. +const popping = keyframes` + 0% { transform: scale(0, 0); } + 40% { transform: scale(0, 0); } + 75% { transform: scale(1.3, 1.3); } + 100% { transform: scale(1, 1); } +`; +const ringBorderWidth = keyframes` + 0% { border-width: 0; } + 50% { border-width: .25em; } + 100% { border-width: 0; } +`; +const ringSize = keyframes` + 0% { width: 0; height: 0; } + 100% { width: 1.5em; height: 1.5em; } +`; +// The pen's own svg wasn't centered via transform (it used top/left + negative margins instead), +// so its "transform: scale(...)" keyframes never had to share the property with anything else. +// Ours centers the box with "translate(-50%, -50%)" (percentage margins need a known box size, +// which an em-sized box doesn't have), so that translate has to be re-asserted on every frame +// here -- otherwise the animation's own transform would replace it outright and the sparkle ring +// would jump to the top-left corner the instant it starts playing. +const sparkleSize = keyframes` + 0% { transform: translate(-50%, -50%) scale(.2, .2); } + 5% { transform: translate(-50%, -50%) scale(.2, .2); } + 85% { transform: translate(-50%, -50%) scale(2, 2); } +`; +const sparkleWidth = keyframes` + 0% { stroke-width: 0; } + 15% { stroke-width: 20; } + 100% { stroke-width: 0; } +`; + +type Props = { + active: boolean; + onClick: () => void; + disabled?: boolean; + ariaLabel: string; + /** The pen's own accent was an arbitrary demo purple (#ca12ea) -- gold reads as "starred" + * without needing to look up what an app-specific brand purple would even mean here. */ + activeColor?: string; + /** Needs to be readable against whatever backdrop this instance sits on -- e.g. white over + * ModelCard's dark hover scrim, vs. a neutral grey over FavoriteButton's plain page bg. */ + inactiveColor?: string; + /** Glyph size in px. Also the `em` baseline the ring/sparkle keyframes (copied from the pen in + * em/viewBox units) scale from, so this must be a real pixel font-size -- not MUI's SvgIcon + * `fontSize="inherit"`, which would leave that baseline undefined. */ + size?: number; + sx?: SxProps; +}; + +/** A favourite/star toggle with the codepen.io/matthewbolanos/pen/yYXQZp burst animation: the + * star pops, a ring flashes outward, and a dashed circle's dashes balloon into sparkles and fade + * -- all in one shot, only when going from unfavourited to favourited. Un-favouriting just fades + * the color back down with no animation, matching the original pen (a burst reads as a + * celebration; removing a favourite isn't one). Doesn't replay on mount even if `active` starts + * true, only on an actual false -> true click. */ +export default function StarToggle({ + active, + onClick, + disabled, + ariaLabel, + activeColor = "#e0a52c", + inactiveColor = "text.disabled", + size = 20, + sx, +}: Props) { + const [playing, setPlaying] = useState(false); + const wasActive = useRef(active); + + useEffect(() => { + if (active && !wasActive.current) { + // Force a reflow before flipping playing back on, so a rapid unfavourite -> refavourite + // still restarts the animation instead of no-op'ing (it's already "on"). + setPlaying(false); + const raf = requestAnimationFrame(() => setPlaying(true)); + wasActive.current = active; + return () => cancelAnimationFrame(raf); + } + wasActive.current = active; + }, [active]); + + useEffect(() => { + if (!playing) return; + // A fixed timeout, not onAnimationEnd: the four animations here finish at three different + // times (.35s ring, .5s pop, .65s sparkle) and onAnimationEnd bubbles from whichever finishes + // first, which would flip `playing` off -- and with it, every other still-running one's + // `sx`-conditional `animation` property -- while the pop and sparkle still have time left. + const PLAYING_DURATION_MS = 650; + const timer = setTimeout(() => setPlaying(false), PLAYING_DURATION_MS); + return () => clearTimeout(timer); + }, [playing]); + + return ( + + + + + + + ); +} diff --git a/frontend/src/pages/ModelDetailPage/FavoriteButton.tsx b/frontend/src/pages/ModelDetailPage/FavoriteButton.tsx index 527a41c..c956e89 100644 --- a/frontend/src/pages/ModelDetailPage/FavoriteButton.tsx +++ b/frontend/src/pages/ModelDetailPage/FavoriteButton.tsx @@ -3,10 +3,9 @@ import { useTranslation } from "react-i18next"; import IconButton from "@mui/material/IconButton"; import CircularProgress from "@mui/material/CircularProgress"; import Tooltip from "@mui/material/Tooltip"; -import FavoriteIcon from "@mui/icons-material/Favorite"; -import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; import { UnauthorizedError } from "../../api/client"; import { type Print, printsApi } from "../../api/prints"; +import StarToggle from "../../components/StarToggle"; type Props = { print: Print; @@ -43,15 +42,13 @@ export default function FavoriteButton({ print, onUpdated, onUnauthorized }: Pro return ( - - {busy ? ( + {busy ? ( + - ) : print.is_favorite ? ( - - ) : ( - - )} - + + ) : ( + + )} ); diff --git a/frontend/src/pages/ModelsPage/ModelCard.tsx b/frontend/src/pages/ModelsPage/ModelCard.tsx index 144c683..4247f20 100644 --- a/frontend/src/pages/ModelsPage/ModelCard.tsx +++ b/frontend/src/pages/ModelsPage/ModelCard.tsx @@ -11,8 +11,6 @@ import CircularProgress from "@mui/material/CircularProgress"; import Tooltip from "@mui/material/Tooltip"; import VisibilityIcon from "@mui/icons-material/Visibility"; import PrintIcon from "@mui/icons-material/Print"; -import FavoriteIcon from "@mui/icons-material/Favorite"; -import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; import { type Print, printsApi } from "../../api/prints"; import { type PreviewMode } from "../../api/settings"; import { type ResolvedTheme } from "../../constants/settingsOptions"; @@ -20,6 +18,7 @@ import { UnauthorizedError } from "../../api/client"; import { useToast } from "../../components/ToastProvider"; import { renderPreviewContent } from "../../components/media/renderPreviewContent"; import { importProviderInfo } from "../../constants/importProviders"; +import StarToggle from "../../components/StarToggle"; import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu"; type Props = { @@ -133,15 +132,13 @@ export default function ModelCard({ item, theme, previewMode, onDeleted, onFavor > - - {favoriteBusy ? ( + {favoriteBusy ? ( + - ) : item.is_favorite ? ( - - ) : ( - - )} - + + ) : ( + + )}