mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
685 lines
22 KiB
TypeScript
685 lines
22 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Check,
|
|
ExternalLink,
|
|
Loader2,
|
|
PackagePlus,
|
|
Search,
|
|
ShieldAlert,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
import {
|
|
fetchMarketplaceSkillTrends,
|
|
fetchTrendingMarketplaceSkills,
|
|
installMarketplaceSkill,
|
|
searchMarketplaceSkills,
|
|
} from "@/lib/api";
|
|
import { notifySkillsChanged } from "@/lib/skill-events";
|
|
import type {
|
|
MarketplaceProvider,
|
|
MarketplaceSkillSummary,
|
|
SkillSummary,
|
|
} from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
import { useClient } from "@/providers/ClientProvider";
|
|
|
|
export function SkillsMarketplace({
|
|
installedSkills,
|
|
installing,
|
|
onInstallingChange,
|
|
}: {
|
|
installedSkills: SkillSummary[];
|
|
installing: string;
|
|
onInstallingChange: (skillId: string) => void;
|
|
}) {
|
|
const { getToken } = useClient();
|
|
const { t } = useTranslation();
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
|
const [trending, setTrending] = useState<MarketplaceSkillSummary[]>([]);
|
|
const [trends, setTrends] = useState<Record<string, number[]>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [trendingLoading, setTrendingLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [provider, setProvider] = useState<MarketplaceProvider>("all");
|
|
const [selected, setSelected] = useState<MarketplaceSkillSummary | null>(null);
|
|
const installedNames = useMemo(
|
|
() => new Set(installedSkills.map((skill) => skill.name)),
|
|
[installedSkills],
|
|
);
|
|
const visibleTrending = useMemo(
|
|
() =>
|
|
provider === "all"
|
|
? trending
|
|
: trending.filter((skill) => skill.provider === provider),
|
|
[provider, trending],
|
|
);
|
|
const visibleResults = useMemo(
|
|
() =>
|
|
provider === "all"
|
|
? results
|
|
: results.filter((skill) => skill.provider === provider),
|
|
[provider, results],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setTrendingLoading(true);
|
|
fetchTrendingMarketplaceSkills(getToken())
|
|
.then((payload) => {
|
|
if (cancelled) return;
|
|
setTrending(payload.skills);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setTrending([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setTrendingLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [getToken]);
|
|
|
|
useEffect(() => {
|
|
const skills = query.trim().length < 2 ? trending : results;
|
|
const unresolved = skills.filter(
|
|
(skill) => skill.provider === "skills_sh" && !(skill.id in trends),
|
|
);
|
|
if (!unresolved.length) return;
|
|
|
|
let cancelled = false;
|
|
fetchMarketplaceSkillTrends(getToken(), unresolved.map((skill) => skill.id))
|
|
.then((payload) => {
|
|
if (!cancelled) {
|
|
setTrends((current) => ({ ...current, ...payload.trends }));
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [getToken, query, results, trending, trends]);
|
|
|
|
useEffect(() => {
|
|
const normalized = query.trim();
|
|
if (normalized.length < 2) {
|
|
setResults([]);
|
|
setLoading(false);
|
|
setError("");
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const timer = window.setTimeout(() => {
|
|
setLoading(true);
|
|
setError("");
|
|
searchMarketplaceSkills(getToken(), normalized)
|
|
.then((payload) => {
|
|
if (cancelled) return;
|
|
setResults(payload.skills);
|
|
})
|
|
.catch((reason: unknown) => {
|
|
if (cancelled) return;
|
|
setResults([]);
|
|
setError(
|
|
reason instanceof Error
|
|
? reason.message
|
|
: t("settings.skills.marketplaceSearchFailed", {
|
|
defaultValue: "Could not search skill marketplaces.",
|
|
}),
|
|
);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
}, 300);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [getToken, query, t]);
|
|
|
|
const install = async (skill: MarketplaceSkillSummary) => {
|
|
setSelected(null);
|
|
onInstallingChange(skill.id);
|
|
setError("");
|
|
try {
|
|
const payload = await installMarketplaceSkill(
|
|
getToken(),
|
|
skill.provider,
|
|
skill.source,
|
|
skill.skill_id,
|
|
skill.version,
|
|
);
|
|
notifySkillsChanged(payload);
|
|
setResults((current) =>
|
|
current.map((item) =>
|
|
item.id === skill.id ? { ...item, installed: true } : item,
|
|
),
|
|
);
|
|
setTrending((current) =>
|
|
current.map((item) =>
|
|
item.id === skill.id ? { ...item, installed: true } : item,
|
|
),
|
|
);
|
|
} catch (reason) {
|
|
setError(
|
|
reason instanceof Error
|
|
? reason.message
|
|
: t("settings.skills.marketplaceInstallFailed", {
|
|
defaultValue: "Could not install this skill.",
|
|
}),
|
|
);
|
|
} finally {
|
|
onInstallingChange("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="relative">
|
|
<Search
|
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={t("settings.skills.marketplaceSearchPlaceholder", {
|
|
defaultValue: "Search skills",
|
|
})}
|
|
aria-label={t("settings.skills.marketplaceSearchLabel", {
|
|
defaultValue: "Search skills",
|
|
})}
|
|
className="h-11 rounded-[14px] bg-settings-surface pl-9"
|
|
/>
|
|
{loading ? (
|
|
<span
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
role="status"
|
|
aria-label={t("settings.skills.marketplaceSearching", {
|
|
defaultValue: "Searching",
|
|
})}
|
|
>
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<ProviderFilter value={provider} onChange={setProvider} />
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="rounded-[14px] bg-destructive/10 px-3 py-2.5 text-[13px] text-destructive">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{query.trim().length < 2 ? (
|
|
<section className="overflow-hidden rounded-[22px] bg-settings-surface">
|
|
<div className="flex flex-col items-start gap-2 px-4 pb-2 pt-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
<h2 className="text-[14px] font-semibold">
|
|
{t("settings.skills.marketplaceTrendingTitle", {
|
|
defaultValue: "Trending by marketplace",
|
|
})}
|
|
</h2>
|
|
{provider !== "all" ? (
|
|
<a
|
|
href={providerUrl(provider)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
{t("settings.skills.marketplaceViewAll", { defaultValue: "View all" })}
|
|
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
{trendingLoading ? (
|
|
<TrendingSkeleton />
|
|
) : visibleTrending.length ? (
|
|
<MarketplaceSkillGroups
|
|
skills={visibleTrending}
|
|
installedNames={installedNames}
|
|
installing={installing}
|
|
trends={trends}
|
|
grouped={provider === "all"}
|
|
onSelect={setSelected}
|
|
/>
|
|
) : (
|
|
<div className="px-5 py-10 text-center text-[13px] text-muted-foreground">
|
|
{t("settings.skills.marketplaceTrendingUnavailable", {
|
|
defaultValue: "Trending skills are temporarily unavailable.",
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
) : !loading && visibleResults.length === 0 && !error ? (
|
|
<div className="rounded-[22px] bg-settings-surface px-5 py-12 text-center text-sm text-muted-foreground">
|
|
{t("settings.skills.marketplaceEmpty", {
|
|
query: query.trim(),
|
|
defaultValue: "No skills found for “{{query}}”.",
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-hidden rounded-[22px] bg-settings-surface">
|
|
<MarketplaceSkillGroups
|
|
skills={visibleResults}
|
|
installedNames={installedNames}
|
|
installing={installing}
|
|
trends={trends}
|
|
grouped={provider === "all"}
|
|
onSelect={setSelected}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<AlertDialog
|
|
open={selected !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setSelected(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent className="rounded-[20px]">
|
|
<AlertDialogHeader>
|
|
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-[12px] bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
|
<ShieldAlert className="h-5 w-5" aria-hidden />
|
|
</div>
|
|
<AlertDialogTitle>
|
|
{t("settings.skills.marketplaceConfirmTitle", {
|
|
name: selected?.name ?? "",
|
|
defaultValue: "Install {{name}}?",
|
|
})}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="space-y-2">
|
|
<span className="block">
|
|
{t("settings.skills.marketplaceConfirmDescription", {
|
|
source: selected?.source ?? "",
|
|
provider: selected ? providerLabel(selected.provider) : "",
|
|
defaultValue:
|
|
"This third-party skill comes from {{provider}} ({{source}}) and may include instructions or executable scripts.",
|
|
})}
|
|
</span>
|
|
<span className="flex flex-wrap items-center gap-2 rounded-md bg-muted px-2 py-1.5 text-[12px] text-foreground">
|
|
{selected ? <ProviderMark provider={selected.provider} /> : null}
|
|
<code>{selected?.source}</code>
|
|
{selected?.version ? <span>v{selected.version}</span> : null}
|
|
</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("common.cancel", { defaultValue: "Cancel" })}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
if (selected) void install(selected);
|
|
}}
|
|
>
|
|
{t("settings.skills.marketplaceConfirmInstall", {
|
|
defaultValue: "Install skill",
|
|
})}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ProviderFilter({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: MarketplaceProvider;
|
|
onChange: (provider: MarketplaceProvider) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const providers: MarketplaceProvider[] = ["all", "skills_sh", "skillhub"];
|
|
return (
|
|
<SegmentedControl
|
|
value={value}
|
|
mode="tabs"
|
|
ariaLabel={t("settings.skills.marketplaceProviderFilter", {
|
|
defaultValue: "Skill source",
|
|
})}
|
|
className="w-fit bg-settings-surface"
|
|
itemClassName="inline-flex h-7 items-center gap-1.5"
|
|
options={providers.map((provider) => ({
|
|
value: provider,
|
|
label: (
|
|
<>
|
|
{provider !== "all" ? <ProviderDot provider={provider} /> : null}
|
|
{provider === "all"
|
|
? t("settings.skills.marketplaceProviderAll", { defaultValue: "All" })
|
|
: providerLabel(provider)}
|
|
</>
|
|
),
|
|
}))}
|
|
onChange={onChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function MarketplaceSkillGroups({
|
|
skills,
|
|
installedNames,
|
|
installing,
|
|
trends,
|
|
grouped,
|
|
onSelect,
|
|
}: {
|
|
skills: MarketplaceSkillSummary[];
|
|
installedNames: Set<string>;
|
|
installing: string;
|
|
trends: Record<string, number[]>;
|
|
grouped: boolean;
|
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const providers: Array<Exclude<MarketplaceProvider, "all">> = [
|
|
"skills_sh",
|
|
"skillhub",
|
|
];
|
|
if (!grouped) {
|
|
return (
|
|
<MarketplaceSkillList
|
|
skills={skills}
|
|
installedNames={installedNames}
|
|
installing={installing}
|
|
trends={trends}
|
|
onSelect={onSelect}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div className="space-y-5 pb-3 pt-2">
|
|
{providers.map((provider) => {
|
|
const providerSkills = skills.filter((skill) => skill.provider === provider);
|
|
if (!providerSkills.length) return null;
|
|
return (
|
|
<section key={provider} className="space-y-1">
|
|
<div className="flex items-center justify-between px-5 py-1.5">
|
|
<ProviderMark provider={provider} />
|
|
<a
|
|
href={providerUrl(provider)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
aria-label={t("settings.skills.marketplaceOpenProvider", {
|
|
provider: providerLabel(provider),
|
|
defaultValue: "Open {{provider}}",
|
|
})}
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
</a>
|
|
</div>
|
|
<MarketplaceSkillList
|
|
skills={providerSkills}
|
|
installedNames={installedNames}
|
|
installing={installing}
|
|
trends={trends}
|
|
onSelect={onSelect}
|
|
/>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarketplaceSkillList({
|
|
skills,
|
|
installedNames,
|
|
installing,
|
|
trends,
|
|
onSelect,
|
|
}: {
|
|
skills: MarketplaceSkillSummary[];
|
|
installedNames: Set<string>;
|
|
installing: string;
|
|
trends: Record<string, number[]>;
|
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1 px-3 pb-3 sm:px-4">
|
|
{skills.map((skill) => (
|
|
<MarketplaceSkillRow
|
|
key={skill.id}
|
|
skill={skill}
|
|
installed={skill.installed || installedNames.has(skill.skill_id)}
|
|
isInstalling={installing === skill.id}
|
|
installBusy={Boolean(installing)}
|
|
trend={trends[skill.id]}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarketplaceSkillRow({
|
|
skill,
|
|
installed,
|
|
isInstalling,
|
|
installBusy,
|
|
trend,
|
|
onSelect,
|
|
}: {
|
|
skill: MarketplaceSkillSummary;
|
|
installed: boolean;
|
|
isInstalling: boolean;
|
|
installBusy: boolean;
|
|
trend?: number[];
|
|
onSelect: (skill: MarketplaceSkillSummary) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const actionLabel = t(
|
|
isInstalling
|
|
? "settings.skills.marketplaceInstalling"
|
|
: installed
|
|
? "settings.skills.marketplaceInstalled"
|
|
: "settings.skills.marketplaceInstall",
|
|
);
|
|
|
|
return (
|
|
<div className="group flex min-w-0 items-center gap-2 px-1 py-3.5 sm:gap-3 sm:px-2">
|
|
{skill.rank ? (
|
|
<span className="w-6 shrink-0 text-right font-mono text-[11px] tabular-nums text-muted-foreground/65 sm:w-7 sm:text-[12px]">
|
|
#{skill.rank}
|
|
</span>
|
|
) : null}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<h3 className="truncate text-[14px] font-semibold text-foreground">
|
|
{skill.name}
|
|
</h3>
|
|
<a
|
|
href={skill.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label={t("settings.skills.marketplaceOpen", {
|
|
name: skill.name,
|
|
provider: providerLabel(skill.provider),
|
|
defaultValue: "Open {{name}} on {{provider}}",
|
|
})}
|
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
</a>
|
|
</div>
|
|
<div className="mt-1 flex min-w-0 items-center gap-1.5 truncate text-[12px] text-muted-foreground">
|
|
{skill.source}
|
|
{skill.version ? <span>· v{skill.version}</span> : null}
|
|
<span>·</span>
|
|
{skill.metric === "installs_24h"
|
|
? t("settings.skills.marketplaceInstalls24h", {
|
|
count: skill.installs,
|
|
formattedCount: skill.installs.toLocaleString(),
|
|
defaultValue: "{{formattedCount}} installs / 24h",
|
|
})
|
|
: t("settings.skills.marketplaceInstalls", {
|
|
count: skill.installs,
|
|
formattedCount: skill.installs.toLocaleString(),
|
|
defaultValue: "{{formattedCount}} installs",
|
|
})}
|
|
</div>
|
|
</div>
|
|
{skill.provider === "skills_sh" ? <TrendSparkline values={trend} /> : null}
|
|
<Button
|
|
type="button"
|
|
aria-label={`${actionLabel} ${skill.name}`}
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={installed || installBusy || !skill.install_supported}
|
|
onClick={() => onSelect(skill)}
|
|
className={cn(
|
|
"h-9 w-9 shrink-0 rounded-full bg-muted/70 text-muted-foreground transition-[opacity,color,background-color,transform] hover:scale-[1.03] hover:bg-foreground hover:text-background focus-visible:bg-foreground focus-visible:text-background focus-visible:opacity-100 sm:opacity-[0.55] sm:group-hover:opacity-100",
|
|
installed &&
|
|
"bg-emerald-500/10 text-emerald-700 disabled:opacity-100 dark:text-emerald-300",
|
|
)}
|
|
title={
|
|
!skill.install_supported
|
|
? t("settings.skills.marketplaceNpxRequired", {
|
|
defaultValue: "Node.js with npx is required",
|
|
})
|
|
: `${actionLabel} ${skill.name}`
|
|
}
|
|
>
|
|
{isInstalling ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : installed ? (
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
) : (
|
|
<PackagePlus className="h-4 w-4" aria-hidden />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProviderMark({
|
|
provider,
|
|
}: {
|
|
provider: Exclude<MarketplaceProvider, "all">;
|
|
}) {
|
|
return (
|
|
<span className="inline-flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
|
|
<ProviderDot provider={provider} />
|
|
{providerLabel(provider)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ProviderDot({
|
|
provider,
|
|
}: {
|
|
provider: Exclude<MarketplaceProvider, "all">;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
provider === "skillhub" ? "bg-[#006EFF]" : "bg-foreground/55",
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
);
|
|
}
|
|
|
|
function providerLabel(provider: Exclude<MarketplaceProvider, "all">): string {
|
|
return provider === "skillhub" ? "SkillHub" : "skills.sh";
|
|
}
|
|
|
|
function providerUrl(provider: Exclude<MarketplaceProvider, "all">): string {
|
|
return provider === "skillhub" ? "https://skillhub.cn" : "https://skills.sh/trending";
|
|
}
|
|
|
|
function TrendSparkline({ values }: { values?: number[] }) {
|
|
const { t } = useTranslation();
|
|
const trendLabel = t("settings.skills.marketplaceTrendLabel", {
|
|
defaultValue: "8-week install trend",
|
|
});
|
|
|
|
if (values === undefined) {
|
|
return <span className="hidden h-[30px] w-24 shrink-0 sm:block" aria-hidden />;
|
|
}
|
|
if (values.length < 2) {
|
|
return (
|
|
<span className="hidden w-24 shrink-0 text-center text-[11px] text-muted-foreground/60 sm:block">
|
|
{t("settings.skills.marketplaceNoTrend", { defaultValue: "No trend yet" })}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const width = 96;
|
|
const height = 30;
|
|
const padding = 2;
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
const range = Math.max(max - min, 1);
|
|
const points = values.map((value, index) => ({
|
|
x: padding + (index / (values.length - 1)) * (width - padding * 2),
|
|
y: padding + ((max - value) / range) * (height - padding * 2),
|
|
}));
|
|
const line = points.slice(1).reduce((path, point, index) => {
|
|
const previous = points[index];
|
|
const middle = (previous.x + point.x) / 2;
|
|
return `${path} C ${middle} ${previous.y}, ${middle} ${point.y}, ${point.x} ${point.y}`;
|
|
}, `M ${points[0].x} ${points[0].y}`);
|
|
const area = `${line} L ${points.at(-1)?.x ?? width} ${height} L ${points[0].x} ${height} Z`;
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className="hidden h-[30px] w-24 shrink-0 overflow-visible text-foreground/40 sm:block"
|
|
role="img"
|
|
aria-label={trendLabel}
|
|
>
|
|
<title>{trendLabel}</title>
|
|
<path d={area} fill="currentColor" opacity="0.06" />
|
|
<path
|
|
d={line}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TrendingSkeleton() {
|
|
return (
|
|
<div className="space-y-1 px-5 pb-3" aria-hidden>
|
|
{Array.from({ length: 5 }, (_, index) => (
|
|
<div key={index} className="flex items-center gap-3 py-4">
|
|
<div className="h-3 w-5 animate-pulse rounded bg-muted" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
|
|
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
|
|
</div>
|
|
<div className="h-8 w-[82px] animate-pulse rounded-full bg-muted sm:w-[92px]" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|