haixunMaster/components/research-map-view.tsx

234 lines
7.4 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import {
Ban,
ExternalLink,
HelpCircle,
Layers,
Target,
UserCircle,
Users,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
SIMILAR_ACCOUNT_SOURCE_LABELS,
threadsProfileUrl,
type ResearchMap,
} from "@/lib/types/research";
import { cn } from "@/lib/utils";
interface ResearchMapViewProps {
map: ResearchMap;
/** 置入模式不顯示相似帳號(僅爆款模仿需要) */
showSimilarAccounts?: boolean;
}
function HeroBlock({
icon,
label,
children,
}: {
icon: React.ReactNode;
label: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl border border-border bg-gradient-to-br from-muted/60 to-muted/20 p-4">
<div className="mb-2 flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80 text-muted-foreground ring-1 ring-border">
{icon}
</div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
</div>
<p className="text-[14px] leading-relaxed text-foreground">{children}</p>
</div>
);
}
function MapBlock({
icon,
title,
count,
children,
className,
tone = "default",
}: {
icon: React.ReactNode;
title: string;
count?: number;
children: React.ReactNode;
className?: string;
tone?: "default" | "exclude";
}) {
return (
<div
className={cn(
"rounded-xl border border-border bg-card p-4",
tone === "exclude" && "border-destructive/25 bg-destructive/[0.03]",
className
)}
>
<div className="mb-3 flex items-center gap-2">
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ring-1 ring-border",
tone === "exclude"
? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground"
)}
>
{icon}
</div>
<h3 className="text-[13px] font-semibold text-foreground">{title}</h3>
{count !== undefined && count > 0 && (
<Badge variant="secondary" className="ml-auto px-1.5 py-0 text-[10px]">
{count}
</Badge>
)}
</div>
{children}
</div>
);
}
export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMapViewProps) {
const accounts = showSimilarAccounts ? (map.similarAccounts ?? []) : [];
return (
<div className="space-y-4">
<div className="grid gap-3 lg:grid-cols-2">
<HeroBlock icon={<Users className="h-3.5 w-3.5" />} label="受眾是誰">
{map.audienceSummary}
</HeroBlock>
<HeroBlock icon={<Target className="h-3.5 w-3.5" />} label="內容目標">
{map.contentGoal}
</HeroBlock>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{map.questions.length > 0 && (
<MapBlock
icon={<HelpCircle className="h-3.5 w-3.5" />}
title="受眾會問什麼"
count={map.questions.length}
className="md:col-span-1 xl:col-span-1"
>
<ol className="space-y-2.5">
{map.questions.map((q, i) => (
<li key={q} className="flex gap-2.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-semibold text-primary">
{i + 1}
</span>
<span className="text-[13px] leading-relaxed text-foreground/90">{q}</span>
</li>
))}
</ol>
</MapBlock>
)}
{map.pillars.length > 0 && (
<MapBlock
icon={<Layers className="h-3.5 w-3.5" />}
title="內容支柱"
count={map.pillars.length}
>
<div className="flex flex-wrap gap-1.5">
{map.pillars.map((p) => (
<span
key={p}
className="inline-flex rounded-lg bg-secondary px-2.5 py-1 text-[12px] font-medium text-secondary-foreground"
>
{p}
</span>
))}
</div>
</MapBlock>
)}
{map.exclusions.length > 0 && (
<MapBlock
icon={<Ban className="h-3.5 w-3.5" />}
title="不要碰"
count={map.exclusions.length}
tone="exclude"
>
<ul className="space-y-1.5">
{map.exclusions.map((ex) => (
<li key={ex} className="flex gap-2 text-[13px] leading-relaxed text-foreground/80">
<span className="shrink-0 text-destructive/70">×</span>
<span>{ex}</span>
</li>
))}
</ul>
</MapBlock>
)}
</div>
{showSimilarAccounts && (
<MapBlock
icon={<UserCircle className="h-3.5 w-3.5" />}
title="相似帳號"
count={accounts.length}
>
{accounts.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{accounts.map((a) => {
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
const sourceLabel = a.source ? SIMILAR_ACCOUNT_SOURCE_LABELS[a.source] : null;
return (
<div
key={a.username}
className="rounded-lg border border-border bg-muted/30 px-3 py-2.5"
>
<div className="flex flex-wrap items-center gap-1.5">
{profileUrl ? (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] font-semibold text-primary hover:underline"
>
@{a.username}
</a>
) : (
<span className="text-[13px] font-semibold text-foreground">
@{a.username}
</span>
)}
{sourceLabel && (
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
{sourceLabel}
</Badge>
)}
</div>
{a.postUrl ? (
<a
href={a.postUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-start gap-1 text-[12px] leading-relaxed text-muted-foreground hover:text-foreground"
>
<span>{a.reason}</span>
<ExternalLink className="mt-0.5 h-3 w-3 shrink-0 opacity-50" />
</a>
) : (
<p className="mt-1 text-[12px] leading-relaxed text-muted-foreground">
{a.reason}
</p>
)}
</div>
);
})}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
調 @帳號
</p>
)}
</MapBlock>
)}
</div>
);
}