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"
>
+4
View File
@@ -674,6 +674,10 @@ const enUS = {
installFailed: 'Installation failed, please try again later',
loadFailed: 'Failed to get plugin list, please try again later',
noDescription: 'No description available',
recommendation: {
pause: 'Pause auto-rotation',
resume: 'Resume auto-rotation',
},
notFound: 'Plugin information not found',
sortBy: 'Sort by',
sort: {
+4
View File
@@ -687,6 +687,10 @@ const esES = {
loadFailed:
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
noDescription: 'No hay descripción disponible',
recommendation: {
pause: 'Pausar rotación automática',
resume: 'Reanudar rotación automática',
},
notFound: 'No se encontró la información del plugin',
sortBy: 'Ordenar por',
sort: {
+6 -1
View File
@@ -680,6 +680,10 @@ const jaJP = {
loadFailed:
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
noDescription: '説明がありません',
recommendation: {
pause: '自動ローテーションを一時停止',
resume: '自動ローテーションを再開',
},
notFound: 'プラグイン情報が見つかりません',
sortBy: '並び順',
sort: {
@@ -761,7 +765,8 @@ const jaJP = {
http: 'HTTPモード',
local: 'ローカル(Stdio',
remote: 'リモート',
localModeDescription: 'Box サンドボックス内でサブプロセスとして MCP サーバーをローカル実行します。',
localModeDescription:
'Box サンドボックス内でサブプロセスとして MCP サーバーをローカル実行します。',
remoteModeDescription:
'URL でリモート MCP サーバーに接続します。トランスポート(Streamable HTTP または SSE)は自動検出されます。',
remoteUrlPlaceholder: 'https://example.com/mcp',
+4
View File
@@ -685,6 +685,10 @@ const ruRU = {
installFailed: 'Ошибка установки, попробуйте позже',
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
noDescription: 'Описание отсутствует',
recommendation: {
pause: 'Приостановить авто-прокрутку',
resume: 'Возобновить авто-прокрутку',
},
notFound: 'Информация о плагине не найдена',
sortBy: 'Сортировать по',
sort: {
+4
View File
@@ -664,6 +664,10 @@ const thTH = {
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
noDescription: 'ไม่มีคำอธิบาย',
recommendation: {
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
resume: 'เล่นการหมุนอัตโนมัติต่อ',
},
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
sortBy: 'เรียงตาม',
sort: {
+4
View File
@@ -679,6 +679,10 @@ const viVN = {
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
noDescription: 'Không có mô tả',
recommendation: {
pause: 'Tạm dừng tự động xoay',
resume: 'Tiếp tục tự động xoay',
},
notFound: 'Không tìm thấy thông tin plugin',
sortBy: 'Sắp xếp theo',
sort: {
+4
View File
@@ -643,6 +643,10 @@ const zhHans = {
installFailed: '安装失败,请稍后重试',
loadFailed: '获取插件列表失败,请稍后重试',
noDescription: '暂无描述',
recommendation: {
pause: '暂停自动轮播',
resume: '继续自动轮播',
},
notFound: '插件信息未找到',
sortBy: '排序方式',
sort: {
+4
View File
@@ -643,6 +643,10 @@ const zhHant = {
installFailed: '安裝失敗,請稍後重試',
loadFailed: '取得插件列表失敗,請稍後重試',
noDescription: '暫無描述',
recommendation: {
pause: '暫停自動輪播',
resume: '繼續自動輪播',
},
notFound: '插件資訊未找到',
sortBy: '排序方式',
sort: {