mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 05:24:23 +00:00
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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -685,6 +685,10 @@ const ruRU = {
|
||||
installFailed: 'Ошибка установки, попробуйте позже',
|
||||
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
||||
noDescription: 'Описание отсутствует',
|
||||
recommendation: {
|
||||
pause: 'Приостановить авто-прокрутку',
|
||||
resume: 'Возобновить авто-прокрутку',
|
||||
},
|
||||
notFound: 'Информация о плагине не найдена',
|
||||
sortBy: 'Сортировать по',
|
||||
sort: {
|
||||
|
||||
@@ -664,6 +664,10 @@ const thTH = {
|
||||
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
||||
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
||||
noDescription: 'ไม่มีคำอธิบาย',
|
||||
recommendation: {
|
||||
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
||||
resume: 'เล่นการหมุนอัตโนมัติต่อ',
|
||||
},
|
||||
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
|
||||
sortBy: 'เรียงตาม',
|
||||
sort: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -643,6 +643,10 @@ const zhHans = {
|
||||
installFailed: '安装失败,请稍后重试',
|
||||
loadFailed: '获取插件列表失败,请稍后重试',
|
||||
noDescription: '暂无描述',
|
||||
recommendation: {
|
||||
pause: '暂停自动轮播',
|
||||
resume: '继续自动轮播',
|
||||
},
|
||||
notFound: '插件信息未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
|
||||
@@ -643,6 +643,10 @@ const zhHant = {
|
||||
installFailed: '安裝失敗,請稍後重試',
|
||||
loadFailed: '取得插件列表失敗,請稍後重試',
|
||||
noDescription: '暫無描述',
|
||||
recommendation: {
|
||||
pause: '暫停自動輪播',
|
||||
resume: '繼續自動輪播',
|
||||
},
|
||||
notFound: '插件資訊未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
|
||||
Reference in New Issue
Block a user