feat(plugin-market): align recommendation carousel with Space (pause + countdown ring)

Port the Space marketplace recommendation carousel UX into the in-app
add-extension page: a 10s auto-advance driven by a smooth countdown ring
that doubles as a pause/resume toggle, and manual prev/next now reset the
countdown. Adds market.recommendation.{pause,resume} across 8 locales.
This commit is contained in:
RockChinQ
2026-06-21 11:48:39 -04:00
parent 42a2c70b14
commit 9daf22d661
9 changed files with 154 additions and 11 deletions
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { ChevronLeft, ChevronRight, Star, Pause, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
@@ -63,8 +63,19 @@ function RecommendationListRow({
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
// Accumulated elapsed time in the current cycle and the timestamp of the last
// animation frame. Kept in refs so the interval reads live values without
// re-subscribing, and so pausing freezes progress in place.
const elapsedRef = useRef<number>(0);
const lastFrameRef = useRef<number>(Date.now());
const pausedRef = useRef<boolean>(false);
const gridRef = useRef<HTMLDivElement>(null);
const AUTO_ADVANCE_MS = 10000;
const plugins = (list.plugins || []).filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
@@ -86,22 +97,65 @@ function RecommendationListRow({
return () => observer.disconnect();
}, [measureCols]);
// Auto-advance every 5 seconds
// Restart the countdown from zero. Called on manual navigation so the user's
// click resets the time-to-next-page indicator.
const resetCountdown = useCallback(() => {
elapsedRef.current = 0;
lastFrameRef.current = Date.now();
setProgress(0);
}, []);
const togglePaused = () => {
setPaused((prev) => {
const next = !prev;
pausedRef.current = next;
// Resync the frame clock on resume so the paused gap isn't counted.
lastFrameRef.current = Date.now();
return next;
});
};
// Auto-advance every AUTO_ADVANCE_MS, driving a smooth countdown ring. The
// interval accumulates elapsed time from refs, so resetCountdown() restarts
// the cycle on manual navigation and pause freezes it without re-creating the
// interval.
useEffect(() => {
if (plugins.length <= perPage) return;
resetCountdown();
const timer = setInterval(() => {
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
}, 5000);
const now = Date.now();
const delta = now - lastFrameRef.current;
lastFrameRef.current = now;
if (pausedRef.current) return;
elapsedRef.current += delta;
if (elapsedRef.current >= AUTO_ADVANCE_MS) {
elapsedRef.current = 0;
setProgress(0);
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
} else {
setProgress(elapsedRef.current / AUTO_ADVANCE_MS);
}
}, 50);
return () => clearInterval(timer);
}, [plugins.length, perPage]);
}, [plugins.length, perPage, resetCountdown]);
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
const safePage = Math.min(page, totalPages - 1);
if (safePage !== page) setPage(safePage);
const goPrev = () => {
setPage((p) => Math.max(0, p - 1));
resetCountdown();
};
const goNext = () => {
setPage((p) => Math.min(totalPages - 1, p + 1));
resetCountdown();
};
const start = safePage * perPage;
const visiblePlugins = plugins.slice(start, start + perPage);
@@ -121,7 +175,7 @@ function RecommendationListRow({
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
onClick={goPrev}
disabled={safePage === 0}
className="h-7 w-7 p-0"
>
@@ -130,10 +184,66 @@ function RecommendationListRow({
<span className="text-xs text-muted-foreground px-1">
{safePage + 1} / {totalPages}
</span>
{/* Auto-advance countdown ring doubles as a pause/resume toggle.
The ring fills as the next flip approaches; click to pause. */}
<button
type="button"
onClick={togglePaused}
title={
paused
? t('market.recommendation.resume')
: t('market.recommendation.pause')
}
aria-label={
paused
? t('market.recommendation.resume')
: t('market.recommendation.pause')
}
className="relative inline-flex h-7 w-7 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
className="-rotate-90 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted-foreground/25"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={
paused ? 'text-muted-foreground/50' : 'text-yellow-500'
}
strokeDasharray={2 * Math.PI * 6}
strokeDashoffset={2 * Math.PI * 6 * (1 - progress)}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center">
{paused ? (
<Play className="h-2.5 w-2.5" />
) : (
<Pause className="h-2.5 w-2.5" />
)}
</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
onClick={goNext}
disabled={safePage >= totalPages - 1}
className="h-7 w-7 p-0"
>