feat(web): add marketplace likes (#2352)

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
Hyu
2026-07-24 15:16:37 +08:00
committed by GitHub
parent 4226f71f05
commit 38e35d328a
18 changed files with 382 additions and 8 deletions
+1
View File
@@ -32,6 +32,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
+9
View File
@@ -24,6 +24,9 @@ dependencies:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.1)
'@fingerprintjs/fingerprintjs':
specifier: ^4.6.2
version: 4.6.2
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.2.2(react-hook-form@7.71.1)
@@ -422,6 +425,12 @@ packages:
levn: 0.4.1
dev: true
/@fingerprintjs/fingerprintjs@4.6.2:
resolution: {integrity: sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw==}
dependencies:
tslib: 2.8.1
dev: false
/@floating-ui/core@1.7.4:
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
dependencies:
@@ -127,7 +127,7 @@ function MarketPageContent({
// Per-format extension counts shown next to the type filter options.
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const [sortOption, setSortOption] = useState<string>(
() => loadMarketFilters().sortOption ?? 'install_count_desc',
() => loadMarketFilters().sortOption ?? 'hot_score_desc',
);
// Persist filter conditions so they survive navigation / reload.
@@ -154,6 +154,12 @@ function MarketPageContent({
// 排序选项
const sortOptions: SortOption[] = [
{
value: 'hot_score_desc',
label: t('market.sort.hottest'),
sortBy: 'hot_score',
sortOrder: 'DESC',
},
{
value: 'created_at_desc',
label: t('market.sort.recentlyAdded'),
@@ -211,7 +217,7 @@ function MarketPageContent({
const option = sortOptions.find((opt) => opt.value === sortOption);
return option
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
: { sortBy: 'install_count', sortOrder: 'DESC' };
: { sortBy: 'hot_score', sortOrder: 'DESC' };
}, [sortOption]);
// 将API响应转换为VO对象
@@ -233,6 +239,7 @@ function MarketPageContent({
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count || 0,
likeCount: plugin.like_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
@@ -40,6 +40,7 @@ function pluginToVO(
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
likeCount: plugin.like_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
@@ -0,0 +1,118 @@
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
import { getCloudServiceClient } from '@/app/infra/http';
const MARKETPLACE_LIKE_CHANGED_EVENT = 'langbot-marketplace-like-changed';
export interface MarketplaceLikeChange {
key: string;
liked: boolean;
likeCount: number;
}
let fingerprintPromise: Promise<string> | undefined;
let likedKeysPromise: Promise<Set<string>> | undefined;
export function marketplaceExtensionKey(
type: string | undefined,
author: string,
name: string,
): string {
return `${type || 'plugin'}:${author}/${name}`;
}
export function getMarketplaceFingerprint(): Promise<string> {
if (!fingerprintPromise) {
fingerprintPromise = FingerprintJS.load({
monitoring: false,
} as LoadOptions & { monitoring: boolean })
.then((agent) => agent.get())
.then((result) => result.visitorId)
.catch((error) => {
fingerprintPromise = undefined;
throw error;
});
}
return fingerprintPromise;
}
async function loadLikedKeys(): Promise<Set<string>> {
if (!likedKeysPromise) {
likedKeysPromise = Promise.all([
getMarketplaceFingerprint(),
getCloudServiceClient(),
])
.then(([fingerprint, client]) =>
client.getMarketplaceLikedExtensions(fingerprint),
)
.then((data) => {
const keys = new Set<string>();
for (const ref of data.extensions || []) {
keys.add(`${ref.type}:${ref.extension_id}`);
}
return keys;
})
.catch((error) => {
likedKeysPromise = undefined;
throw error;
});
}
return likedKeysPromise;
}
export async function getMarketplaceExtensionLiked(
type: string | undefined,
author: string,
name: string,
): Promise<boolean> {
const likedKeys = await loadLikedKeys();
return likedKeys.has(marketplaceExtensionKey(type, author, name));
}
export async function toggleMarketplaceExtensionLike(
type: string | undefined,
author: string,
name: string,
liked: boolean,
): Promise<MarketplaceLikeChange> {
const extensionType = type || 'plugin';
const [fingerprint, likedKeys, client] = await Promise.all([
getMarketplaceFingerprint(),
loadLikedKeys(),
getCloudServiceClient(),
]);
const result = await client.setMarketplaceExtensionLike(
extensionType,
author,
name,
fingerprint,
liked,
);
const key = marketplaceExtensionKey(extensionType, author, name);
if (result.liked) {
likedKeys.add(key);
} else {
likedKeys.delete(key);
}
const change: MarketplaceLikeChange = {
key,
liked: result.liked,
likeCount: result.like_count,
};
window.dispatchEvent(
new CustomEvent<MarketplaceLikeChange>(MARKETPLACE_LIKE_CHANGED_EVENT, {
detail: change,
}),
);
return change;
}
export function subscribeMarketplaceLikeChanges(
listener: (change: MarketplaceLikeChange) => void,
): () => void {
const handler = (event: Event) => {
listener((event as CustomEvent<MarketplaceLikeChange>).detail);
};
window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
return () =>
window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
}
@@ -3,7 +3,7 @@ import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package, ExternalLink } from 'lucide-react';
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
import {
Tooltip,
TooltipContent,
@@ -11,6 +11,13 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import {
getMarketplaceExtensionLiked,
marketplaceExtensionKey,
subscribeMarketplaceLikeChanges,
toggleMarketplaceExtensionLike,
} from '../marketplace-likes';
export default function PluginMarketCardComponent({
cardVO,
@@ -25,6 +32,9 @@ export default function PluginMarketCardComponent({
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(cardVO.likeCount);
const [isLiking, setIsLiking] = useState(false);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
@@ -52,6 +62,37 @@ export default function PluginMarketCardComponent({
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
useEffect(() => {
setLikeCount(cardVO.likeCount);
}, [cardVO.likeCount]);
useEffect(() => {
let cancelled = false;
getMarketplaceExtensionLiked(cardVO.type, cardVO.author, cardVO.pluginName)
.then((value) => {
if (!cancelled) setLiked(value);
})
.catch(() => {
// The count still renders if the viewer-state request is unavailable.
});
return () => {
cancelled = true;
};
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
useEffect(() => {
const key = marketplaceExtensionKey(
cardVO.type,
cardVO.author,
cardVO.pluginName,
);
return subscribeMarketplaceLikeChanges((change) => {
if (change.key !== key) return;
setLiked(change.liked);
setLikeCount(change.likeCount);
});
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -89,6 +130,29 @@ export default function PluginMarketCardComponent({
onInstall?.(cardVO);
};
const handleLikeClick = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (isLiking) return;
setIsLiking(true);
try {
const change = await toggleMarketplaceExtensionLike(
cardVO.type,
cardVO.author,
cardVO.pluginName,
!liked,
);
setLiked(change.liked);
setLikeCount(change.likeCount);
} catch {
toast.error(t('market.likeFailed'));
} finally {
setIsLiking(false);
}
};
return (
<div
role="button"
@@ -97,7 +161,10 @@ export default function PluginMarketCardComponent({
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
onClick={handleInstallClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
if (
event.target === event.currentTarget &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault();
handleInstallClick();
}
@@ -177,6 +244,26 @@ export default function PluginMarketCardComponent({
</div>
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="sm"
title={t(liked ? 'market.unlike' : 'market.like')}
aria-label={t(liked ? 'market.unlike' : 'market.like')}
aria-pressed={liked}
disabled={isLiking}
className="h-7 min-w-7 gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
onClick={handleLikeClick}
>
{isLiking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Heart
className={`h-3.5 w-3.5 ${liked ? 'fill-red-500 text-red-500' : ''}`}
/>
)}
<span className="text-xs tabular-nums">{likeCount}</span>
</Button>
<Button
type="button"
variant="ghost"
@@ -5,6 +5,7 @@ export interface IPluginMarketCardVO {
label: string;
description: string;
installCount: number;
likeCount?: number;
iconURL: string;
githubURL: string;
version: string;
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
iconURL: string;
githubURL: string;
installCount: number;
likeCount: number;
version: string;
components?: Record<string, number>;
tags?: string[];
@@ -35,6 +37,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.iconURL = prop.iconURL;
this.githubURL = prop.githubURL;
this.installCount = prop.installCount;
this.likeCount = prop.likeCount ?? 0;
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
@@ -48,6 +48,8 @@ export interface PluginV4 {
repository: string;
tags: string[];
install_count: number;
like_count?: number;
hot_score?: number;
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
+32 -4
View File
@@ -117,6 +117,32 @@ export class CloudServiceClient extends BaseHttpClient {
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
}
public getMarketplaceLikedExtensions(
fingerprint: string,
): Promise<{ extensions: Array<{ type: string; extension_id: string }> }> {
return this.post('/api/v1/marketplace/extensions/likes/status', {
fingerprint,
});
}
public setMarketplaceExtensionLike(
type: string,
author: string,
name: string,
fingerprint: string,
liked: boolean,
): Promise<{
type: string;
extension_id: string;
liked: boolean;
like_count: number;
}> {
return this.put(
`/api/v1/marketplace/extensions/${encodeURIComponent(type)}/${encodeURIComponent(author)}/${encodeURIComponent(name)}/like`,
{ fingerprint, liked },
);
}
private async searchMarketplaceExtensionsLegacy(data: {
query?: string;
page: number;
@@ -128,6 +154,8 @@ export class CloudServiceClient extends BaseHttpClient {
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
const query = data.query || '';
const legacySortBy =
data.sort_by === 'hot_score' ? 'install_count' : data.sort_by;
if (
data.type_filter === 'plugin' ||
@@ -139,7 +167,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
data.sort_by,
legacySortBy,
data.sort_order,
data.component_filter,
data.tags_filter,
@@ -157,7 +185,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
data.sort_by,
legacySortBy,
data.sort_order,
undefined,
data.tags_filter,
@@ -167,7 +195,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
data.sort_by,
legacySortBy,
data.sort_order,
undefined,
data.tags_filter,
@@ -177,7 +205,7 @@ export class CloudServiceClient extends BaseHttpClient {
query,
data.page,
data.page_size,
data.sort_by,
legacySortBy,
data.sort_order,
undefined,
data.tags_filter,
+4
View File
@@ -700,6 +700,7 @@ const enUS = {
notFound: 'Plugin information not found',
sortBy: 'Sort by',
sort: {
hottest: 'Most Popular',
recentlyAdded: 'Recently Added',
recentlyUpdated: 'Recently Updated',
mostDownloads: 'Most Downloads',
@@ -707,6 +708,9 @@ const enUS = {
},
downloads: 'downloads',
download: 'Download',
like: 'Like',
unlike: 'Unlike',
likeFailed: 'Failed to update like. Please try again.',
repository: 'Repository',
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
+4
View File
@@ -713,6 +713,7 @@ const esES = {
notFound: 'No se encontró la información del plugin',
sortBy: 'Ordenar por',
sort: {
hottest: 'Más populares',
recentlyAdded: 'Añadidos recientemente',
recentlyUpdated: 'Actualizados recientemente',
mostDownloads: 'Más descargas',
@@ -720,6 +721,9 @@ const esES = {
},
downloads: 'descargas',
download: 'Descargar',
like: 'Me gusta',
unlike: 'Ya no me gusta',
likeFailed: 'No se pudo actualizar el Me gusta. Inténtalo de nuevo.',
repository: 'Repositorio',
downloadFailed: 'Error en la descarga',
noReadme: 'Este plugin no proporciona documentación README',
+4
View File
@@ -706,6 +706,7 @@ const jaJP = {
notFound: 'プラグイン情報が見つかりません',
sortBy: '並び順',
sort: {
hottest: '人気順',
recentlyAdded: '最近追加',
recentlyUpdated: '最近更新',
mostDownloads: 'ダウンロード数多',
@@ -713,6 +714,9 @@ const jaJP = {
},
downloads: '回ダウンロード',
download: 'ダウンロード',
like: 'いいね',
unlike: 'いいねを解除',
likeFailed: 'いいねを更新できませんでした。もう一度お試しください。',
repository: 'リポジトリ',
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
+4
View File
@@ -711,6 +711,7 @@ const ruRU = {
notFound: 'Информация о плагине не найдена',
sortBy: 'Сортировать по',
sort: {
hottest: 'По популярности',
recentlyAdded: 'Недавно добавленные',
recentlyUpdated: 'Недавно обновлённые',
mostDownloads: 'Больше всего загрузок',
@@ -718,6 +719,9 @@ const ruRU = {
},
downloads: 'загрузок',
download: 'Скачать',
like: 'Нравится',
unlike: 'Убрать отметку',
likeFailed: 'Не удалось обновить отметку. Повторите попытку.',
repository: 'Репозиторий',
downloadFailed: 'Ошибка загрузки',
noReadme: 'Этот плагин не предоставляет документацию README',
+4
View File
@@ -690,6 +690,7 @@ const thTH = {
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
sortBy: 'เรียงตาม',
sort: {
hottest: 'ยอดนิยมที่สุด',
recentlyAdded: 'เพิ่มล่าสุด',
recentlyUpdated: 'อัปเดตล่าสุด',
mostDownloads: 'ดาวน์โหลดมากที่สุด',
@@ -697,6 +698,9 @@ const thTH = {
},
downloads: 'ดาวน์โหลด',
download: 'ดาวน์โหลด',
like: 'ถูกใจ',
unlike: 'เลิกถูกใจ',
likeFailed: 'อัปเดตการถูกใจไม่สำเร็จ โปรดลองอีกครั้ง',
repository: 'Repository',
downloadFailed: 'ดาวน์โหลดล้มเหลว',
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
+4
View File
@@ -705,6 +705,7 @@ const viVN = {
notFound: 'Không tìm thấy thông tin plugin',
sortBy: 'Sắp xếp theo',
sort: {
hottest: 'Phổ biến nhất',
recentlyAdded: 'Mới thêm gần đây',
recentlyUpdated: 'Mới cập nhật gần đây',
mostDownloads: 'Tải nhiều nhất',
@@ -712,6 +713,9 @@ const viVN = {
},
downloads: 'lượt tải',
download: 'Tải xuống',
like: 'Thích',
unlike: 'Bỏ thích',
likeFailed: 'Không thể cập nhật lượt thích. Vui lòng thử lại.',
repository: 'Kho lưu trữ',
downloadFailed: 'Tải xuống thất bại',
noReadme: 'Plugin này không cung cấp tài liệu README',
+4
View File
@@ -668,6 +668,7 @@ const zhHans = {
notFound: '插件信息未找到',
sortBy: '排序方式',
sort: {
hottest: '热度最高',
recentlyAdded: '最近新增',
recentlyUpdated: '最近更新',
mostDownloads: '最多下载',
@@ -675,6 +676,9 @@ const zhHans = {
},
downloads: '次下载',
download: '下载',
like: '点赞',
unlike: '取消点赞',
likeFailed: '点赞失败,请稍后重试',
repository: '代码仓库',
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
+4
View File
@@ -668,6 +668,7 @@ const zhHant = {
notFound: '插件資訊未找到',
sortBy: '排序方式',
sort: {
hottest: '熱度最高',
recentlyAdded: '最近新增',
recentlyUpdated: '最近更新',
mostDownloads: '最多下載',
@@ -675,6 +676,9 @@ const zhHant = {
},
downloads: '次下載',
download: '下載',
like: '按讚',
unlike: '取消按讚',
likeFailed: '按讚失敗,請稍後重試',
repository: '代碼倉庫',
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const webRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const read = (relativePath) =>
fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => {
const market = read(
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
);
const card = read(
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
);
const cardVO = read(
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts',
);
const entity = read('src/app/infra/entities/plugin/index.ts');
const client = read('src/app/infra/http/CloudServiceClient.ts');
const likes = read(
'src/app/home/plugins/components/plugin-market/marketplace-likes.ts',
);
assert.match(
market,
/hot_score_desc/,
'market must expose and default to hot-score sorting',
);
assert.match(
market,
/sortBy:\s*['"]hot_score['"]/,
'hot option must use the shared API field',
);
assert.match(
entity,
/like_count\??:\s*number/,
'marketplace extension responses must carry like counts',
);
assert.match(
cardVO,
/likeCount:\s*number/,
'market cards must carry like counts',
);
assert.match(card, /\bHeart\b/, 'market cards must render a like affordance');
assert.match(
card,
/toggleMarketplaceExtensionLike/,
'market cards must support liking and unliking',
);
assert.match(
client,
/marketplace\/extensions\/likes/,
'client must load browser likes',
);
assert.match(
client,
/setMarketplaceExtensionLike\(/,
'client must update likes through the shared API',
);
assert.match(
client,
/data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/,
'older Space servers must fall back from hot score to install sorting',
);
assert.match(
likes,
/@fingerprintjs\/fingerprintjs/,
'anonymous likes must use browser fingerprinting',
);
assert.match(
likes,
/FingerprintJS\.load\(\{/,
'fingerprint agent must be initialized with options',
);
assert.match(
likes,
/monitoring:\s*false/,
'fingerprinting must not send vendor monitoring requests',
);
});