From 38e35d328a13f6561c14a21ea734c2fa75c0e4d7 Mon Sep 17 00:00:00 2001 From: Hyu Date: Fri, 24 Jul 2026 15:16:37 +0800 Subject: [PATCH] feat(web): add marketplace likes (#2352) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- web/package.json | 1 + web/pnpm-lock.yaml | 9 ++ .../plugin-market/PluginMarketComponent.tsx | 11 +- .../plugin-market/RecommendationLists.tsx | 1 + .../plugin-market/marketplace-likes.ts | 118 ++++++++++++++++++ .../PluginMarketCardComponent.tsx | 91 +++++++++++++- .../plugin-market-card/PluginMarketCardVO.ts | 3 + web/src/app/infra/entities/plugin/index.ts | 2 + web/src/app/infra/http/CloudServiceClient.ts | 36 +++++- web/src/i18n/locales/en-US.ts | 4 + web/src/i18n/locales/es-ES.ts | 4 + web/src/i18n/locales/ja-JP.ts | 4 + web/src/i18n/locales/ru-RU.ts | 4 + web/src/i18n/locales/th-TH.ts | 4 + web/src/i18n/locales/vi-VN.ts | 4 + web/src/i18n/locales/zh-Hans.ts | 4 + web/src/i18n/locales/zh-Hant.ts | 4 + web/tests/unit/marketplace-likes.test.mjs | 86 +++++++++++++ 18 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 web/src/app/home/plugins/components/plugin-market/marketplace-likes.ts create mode 100644 web/tests/unit/marketplace-likes.test.mjs diff --git a/web/package.json b/web/package.json index be15b84af..e896b975e 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ab0954fe5..10521306b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: diff --git a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx index 1687f3c47..d9ab23a6b 100644 --- a/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx @@ -127,7 +127,7 @@ function MarketPageContent({ // Per-format extension counts shown next to the type filter options. const [typeCounts, setTypeCounts] = useState>({}); const [sortOption, setSortOption] = useState( - () => 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, diff --git a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx index 04c72a609..8a6554e0e 100644 --- a/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx +++ b/web/src/app/home/plugins/components/plugin-market/RecommendationLists.tsx @@ -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, diff --git a/web/src/app/home/plugins/components/plugin-market/marketplace-likes.ts b/web/src/app/home/plugins/components/plugin-market/marketplace-likes.ts new file mode 100644 index 000000000..29751f884 --- /dev/null +++ b/web/src/app/home/plugins/components/plugin-market/marketplace-likes.ts @@ -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 | undefined; +let likedKeysPromise: Promise> | undefined; + +export function marketplaceExtensionKey( + type: string | undefined, + author: string, + name: string, +): string { + return `${type || 'plugin'}:${author}/${name}`; +} + +export function getMarketplaceFingerprint(): Promise { + 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> { + if (!likedKeysPromise) { + likedKeysPromise = Promise.all([ + getMarketplaceFingerprint(), + getCloudServiceClient(), + ]) + .then(([fingerprint, client]) => + client.getMarketplaceLikedExtensions(fingerprint), + ) + .then((data) => { + const keys = new Set(); + 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 { + 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 { + 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(MARKETPLACE_LIKE_CHANGED_EVENT, { + detail: change, + }), + ); + return change; +} + +export function subscribeMarketplaceLikeChanges( + listener: (change: MarketplaceLikeChange) => void, +): () => void { + const handler = (event: Event) => { + listener((event as CustomEvent).detail); + }; + window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler); + return () => + window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler); +} diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx index c3157b277..c980b9cd1 100644 --- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx @@ -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(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, + ) => { + 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 (
{ - 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({
+