haixunMaster/components/research-map-view.tsx

255 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
Ban,
ExternalLink,
HelpCircle,
Layers,
Target,
UserCircle,
Users,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
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;
const confidenceLabel = a.confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[a.confidence] : null;
const confidenceColor =
a.confidence === "high"
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
: a.confidence === "medium"
? "bg-amber-500/10 text-amber-600 border-amber-200"
: "bg-muted text-muted-foreground border-border";
const daysSinceActive = a.lastActiveAt
? Math.floor((Date.now() - new Date(a.lastActiveAt).getTime()) / 86400000)
: 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>
)}
{confidenceLabel && (
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
{confidenceLabel}
</Badge>
)}
{sourceLabel && (
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
{sourceLabel}
</Badge>
)}
</div>
{daysSinceActive !== null && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
</p>
)}
{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>
);
}