mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-21 09:47:13 +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 { 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 { Button } from '@/components/ui/button';
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||||
@@ -63,8 +63,19 @@ function RecommendationListRow({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [perPage, setPerPage] = useState(4);
|
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 gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const AUTO_ADVANCE_MS = 10000;
|
||||||
|
|
||||||
const plugins = (list.plugins || []).filter((plugin) => {
|
const plugins = (list.plugins || []).filter((plugin) => {
|
||||||
// Hide plugins that only contain deprecated KnowledgeRetriever components
|
// Hide plugins that only contain deprecated KnowledgeRetriever components
|
||||||
const keys = Object.keys(plugin.components || {});
|
const keys = Object.keys(plugin.components || {});
|
||||||
@@ -86,22 +97,65 @@ function RecommendationListRow({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [measureCols]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (plugins.length <= perPage) return;
|
if (plugins.length <= perPage) return;
|
||||||
|
resetCountdown();
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setPage((p) => {
|
const now = Date.now();
|
||||||
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
|
const delta = now - lastFrameRef.current;
|
||||||
return p >= tp - 1 ? 0 : p + 1;
|
lastFrameRef.current = now;
|
||||||
});
|
if (pausedRef.current) return;
|
||||||
}, 5000);
|
|
||||||
|
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);
|
return () => clearInterval(timer);
|
||||||
}, [plugins.length, perPage]);
|
}, [plugins.length, perPage, resetCountdown]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
|
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
|
||||||
const safePage = Math.min(page, totalPages - 1);
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
if (safePage !== page) setPage(safePage);
|
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 start = safePage * perPage;
|
||||||
const visiblePlugins = plugins.slice(start, start + perPage);
|
const visiblePlugins = plugins.slice(start, start + perPage);
|
||||||
|
|
||||||
@@ -121,7 +175,7 @@ function RecommendationListRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={goPrev}
|
||||||
disabled={safePage === 0}
|
disabled={safePage === 0}
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
>
|
>
|
||||||
@@ -130,10 +184,66 @@ function RecommendationListRow({
|
|||||||
<span className="text-xs text-muted-foreground px-1">
|
<span className="text-xs text-muted-foreground px-1">
|
||||||
{safePage + 1} / {totalPages}
|
{safePage + 1} / {totalPages}
|
||||||
</span>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
onClick={goNext}
|
||||||
disabled={safePage >= totalPages - 1}
|
disabled={safePage >= totalPages - 1}
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -674,6 +674,10 @@ const enUS = {
|
|||||||
installFailed: 'Installation failed, please try again later',
|
installFailed: 'Installation failed, please try again later',
|
||||||
loadFailed: 'Failed to get plugin list, please try again later',
|
loadFailed: 'Failed to get plugin list, please try again later',
|
||||||
noDescription: 'No description available',
|
noDescription: 'No description available',
|
||||||
|
recommendation: {
|
||||||
|
pause: 'Pause auto-rotation',
|
||||||
|
resume: 'Resume auto-rotation',
|
||||||
|
},
|
||||||
notFound: 'Plugin information not found',
|
notFound: 'Plugin information not found',
|
||||||
sortBy: 'Sort by',
|
sortBy: 'Sort by',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -687,6 +687,10 @@ const esES = {
|
|||||||
loadFailed:
|
loadFailed:
|
||||||
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
|
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
|
||||||
noDescription: 'No hay descripción disponible',
|
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',
|
notFound: 'No se encontró la información del plugin',
|
||||||
sortBy: 'Ordenar por',
|
sortBy: 'Ordenar por',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -680,6 +680,10 @@ const jaJP = {
|
|||||||
loadFailed:
|
loadFailed:
|
||||||
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
||||||
noDescription: '説明がありません',
|
noDescription: '説明がありません',
|
||||||
|
recommendation: {
|
||||||
|
pause: '自動ローテーションを一時停止',
|
||||||
|
resume: '自動ローテーションを再開',
|
||||||
|
},
|
||||||
notFound: 'プラグイン情報が見つかりません',
|
notFound: 'プラグイン情報が見つかりません',
|
||||||
sortBy: '並び順',
|
sortBy: '並び順',
|
||||||
sort: {
|
sort: {
|
||||||
@@ -761,7 +765,8 @@ const jaJP = {
|
|||||||
http: 'HTTPモード',
|
http: 'HTTPモード',
|
||||||
local: 'ローカル(Stdio)',
|
local: 'ローカル(Stdio)',
|
||||||
remote: 'リモート',
|
remote: 'リモート',
|
||||||
localModeDescription: 'Box サンドボックス内でサブプロセスとして MCP サーバーをローカル実行します。',
|
localModeDescription:
|
||||||
|
'Box サンドボックス内でサブプロセスとして MCP サーバーをローカル実行します。',
|
||||||
remoteModeDescription:
|
remoteModeDescription:
|
||||||
'URL でリモート MCP サーバーに接続します。トランスポート(Streamable HTTP または SSE)は自動検出されます。',
|
'URL でリモート MCP サーバーに接続します。トランスポート(Streamable HTTP または SSE)は自動検出されます。',
|
||||||
remoteUrlPlaceholder: 'https://example.com/mcp',
|
remoteUrlPlaceholder: 'https://example.com/mcp',
|
||||||
|
|||||||
@@ -685,6 +685,10 @@ const ruRU = {
|
|||||||
installFailed: 'Ошибка установки, попробуйте позже',
|
installFailed: 'Ошибка установки, попробуйте позже',
|
||||||
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
||||||
noDescription: 'Описание отсутствует',
|
noDescription: 'Описание отсутствует',
|
||||||
|
recommendation: {
|
||||||
|
pause: 'Приостановить авто-прокрутку',
|
||||||
|
resume: 'Возобновить авто-прокрутку',
|
||||||
|
},
|
||||||
notFound: 'Информация о плагине не найдена',
|
notFound: 'Информация о плагине не найдена',
|
||||||
sortBy: 'Сортировать по',
|
sortBy: 'Сортировать по',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -664,6 +664,10 @@ const thTH = {
|
|||||||
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
||||||
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
||||||
noDescription: 'ไม่มีคำอธิบาย',
|
noDescription: 'ไม่มีคำอธิบาย',
|
||||||
|
recommendation: {
|
||||||
|
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
||||||
|
resume: 'เล่นการหมุนอัตโนมัติต่อ',
|
||||||
|
},
|
||||||
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
|
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
|
||||||
sortBy: 'เรียงตาม',
|
sortBy: 'เรียงตาม',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -679,6 +679,10 @@ const viVN = {
|
|||||||
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
|
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',
|
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
|
||||||
noDescription: 'Không có mô tả',
|
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',
|
notFound: 'Không tìm thấy thông tin plugin',
|
||||||
sortBy: 'Sắp xếp theo',
|
sortBy: 'Sắp xếp theo',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -643,6 +643,10 @@ const zhHans = {
|
|||||||
installFailed: '安装失败,请稍后重试',
|
installFailed: '安装失败,请稍后重试',
|
||||||
loadFailed: '获取插件列表失败,请稍后重试',
|
loadFailed: '获取插件列表失败,请稍后重试',
|
||||||
noDescription: '暂无描述',
|
noDescription: '暂无描述',
|
||||||
|
recommendation: {
|
||||||
|
pause: '暂停自动轮播',
|
||||||
|
resume: '继续自动轮播',
|
||||||
|
},
|
||||||
notFound: '插件信息未找到',
|
notFound: '插件信息未找到',
|
||||||
sortBy: '排序方式',
|
sortBy: '排序方式',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
@@ -643,6 +643,10 @@ const zhHant = {
|
|||||||
installFailed: '安裝失敗,請稍後重試',
|
installFailed: '安裝失敗,請稍後重試',
|
||||||
loadFailed: '取得插件列表失敗,請稍後重試',
|
loadFailed: '取得插件列表失敗,請稍後重試',
|
||||||
noDescription: '暫無描述',
|
noDescription: '暫無描述',
|
||||||
|
recommendation: {
|
||||||
|
pause: '暫停自動輪播',
|
||||||
|
resume: '繼續自動輪播',
|
||||||
|
},
|
||||||
notFound: '插件資訊未找到',
|
notFound: '插件資訊未找到',
|
||||||
sortBy: '排序方式',
|
sortBy: '排序方式',
|
||||||
sort: {
|
sort: {
|
||||||
|
|||||||
Reference in New Issue
Block a user