mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-11 05:20:57 +00:00
fix(market): sync plugin market UI improvements from Space (#2056)
* fix(market): sync plugin market UI from space - page size 12, full list display, fix double separator, adaptive tag display * fix: lint and prettier formatting * fix: prettier formatting for remaining files
This commit is contained in:
@@ -1,13 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
Suspense,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -70,7 +63,7 @@ function MarketPageContent({
|
|||||||
RecommendationList[]
|
RecommendationList[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const pageSize = 16; // 每页16个,4行x4列
|
const pageSize = 12; // 每页12个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -330,38 +323,7 @@ function MarketPageContent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 计算所有推荐插件的 ID 集合
|
const visiblePlugins = plugins;
|
||||||
const recommendedPluginIds = useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
recommendationLists.forEach((list) => {
|
|
||||||
list.plugins.forEach((plugin) => {
|
|
||||||
ids.add(`${plugin.author} / ${plugin.name}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
}, [recommendationLists]);
|
|
||||||
|
|
||||||
// 过滤掉已在推荐列表中展示的插件
|
|
||||||
// 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤
|
|
||||||
// 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现
|
|
||||||
// 但是推荐列表只在第一页且无筛选时显示。
|
|
||||||
// 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。
|
|
||||||
const visiblePlugins = useMemo(() => {
|
|
||||||
const showRecommendations =
|
|
||||||
!searchQuery && componentFilter === 'all' && selectedTags.length === 0;
|
|
||||||
|
|
||||||
if (!showRecommendations) {
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId));
|
|
||||||
}, [
|
|
||||||
plugins,
|
|
||||||
recommendedPluginIds,
|
|
||||||
searchQuery,
|
|
||||||
componentFilter,
|
|
||||||
selectedTags,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ function RecommendationListRow({
|
|||||||
list,
|
list,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
list: RecommendationList;
|
list: RecommendationList;
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (author: string, pluginName: string) => void;
|
onInstall: (author: string, pluginName: string) => void;
|
||||||
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -143,7 +145,9 @@ function RecommendationListRow({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && <div className="border-b border-border mt-6" />}
|
{totalPages > 1 && !isLast && (
|
||||||
|
<div className="border-b border-border mt-6" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,12 +165,13 @@ export function RecommendationLists({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{lists.map((list) => (
|
{lists.map((list, index) => (
|
||||||
<RecommendationListRow
|
<RecommendationListRow
|
||||||
key={list.uuid}
|
key={list.uuid}
|
||||||
list={list}
|
list={list}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
isLast={index === lists.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="border-b border-border mb-6" />
|
<div className="border-b border-border mb-6" />
|
||||||
|
|||||||
+55
-13
@@ -17,7 +17,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Info,
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export default function PluginMarketCardComponent({
|
export default function PluginMarketCardComponent({
|
||||||
@@ -31,6 +31,43 @@ export default function PluginMarketCardComponent({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [visibleTags, setVisibleTags] = useState(2);
|
||||||
|
|
||||||
|
// Measure how many tags fit in the bottom row
|
||||||
|
useEffect(() => {
|
||||||
|
const tags = cardVO.tags;
|
||||||
|
if (!bottomRef.current || !tags || tags.length === 0) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const container = bottomRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
const availableForTags = width - 140 - 80;
|
||||||
|
if (availableForTags <= 0) {
|
||||||
|
setVisibleTags(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tagWidth = 80;
|
||||||
|
const plusBadgeWidth = 40;
|
||||||
|
const maxTags = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
|
||||||
|
);
|
||||||
|
if (maxTags >= tags.length) {
|
||||||
|
setVisibleTags(tags.length);
|
||||||
|
} else {
|
||||||
|
setVisibleTags(Math.max(1, maxTags));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(bottomRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [cardVO.tags]);
|
||||||
|
|
||||||
|
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||||
|
|
||||||
function handleInstallClick(e: React.MouseEvent) {
|
function handleInstallClick(e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -135,10 +172,13 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下部分:下载量、标签和组件列表 */}
|
{/* 下部分:下载量、标签和组件列表 */}
|
||||||
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
|
<div
|
||||||
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
|
ref={bottomRef}
|
||||||
|
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||||
{/* 下载数量 */}
|
{/* 下载数量 */}
|
||||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
|
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -156,14 +196,14 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags - adaptive */}
|
||||||
{cardVO.tags && cardVO.tags.length > 0 && (
|
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||||
{cardVO.tags.slice(0, 2).map((tag) => (
|
{cardVO.tags.slice(0, visibleTags).map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
|
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-2.5 h-2.5 flex-shrink-0"
|
className="w-2.5 h-2.5 flex-shrink-0"
|
||||||
@@ -178,15 +218,17 @@ export default function PluginMarketCardComponent({
|
|||||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
||||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="truncate">{tagNames[tag] || tag}</span>
|
<span className="truncate max-w-[5rem]">
|
||||||
|
{tagNames[tag] || tag}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{cardVO.tags.length > 2 && (
|
{remainingTags > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
|
className="text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
+{cardVO.tags.length - 2}
|
+{remainingTags}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user