mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
feat(web): add marketplace likes (#2352)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+89
-2
@@ -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"
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user