From 9daf22d6613816c159467e9f296a066aef5870c8 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sun, 21 Jun 2026 11:48:39 -0400 Subject: [PATCH] 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. --- .../plugin-market/RecommendationLists.tsx | 130 ++++++++++++++++-- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/es-ES.ts | 4 + web/src/i18n/locales/ja-JP.ts | 7 +- web/src/i18n/locales/ru-RU.ts | 4 + web/src/i18n/locales/th-TH.ts | 4 + web/src/i18n/locales/vi-VN.ts | 4 + web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + 9 files changed, 154 insertions(+), 11 deletions(-) diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index ffb5b339b..04c72a609 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -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(0); + const lastFrameRef = useRef(Date.now()); + const pausedRef = useRef(false); const gridRef = useRef(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({