mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 13:26:08 +00:00
feat(web): add marketplace likes (#2352)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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);
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ドキュメントを提供していません',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 文档',
|
||||
|
||||
@@ -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 文件',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user