haixunMaster/components/viral-analysis-panel.tsx

199 lines
7.1 KiB
TypeScript
Raw Permalink 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 { useEffect, useState } from "react";
import { Copy, Flame, ImageIcon, Loader2, MessageCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { notify } from "@/lib/notifications/store";
import type { ViralAnalysis } from "@/lib/types/viral";
interface ViralAnalysisPanelProps {
scanItemId: string;
mediaUrls?: string[];
viralAnalysis?: ViralAnalysis | null;
onUpdate: () => void;
}
export function ViralAnalysisPanel({
scanItemId,
mediaUrls = [],
viralAnalysis,
onUpdate,
}: ViralAnalysisPanelProps) {
const [analyzing, setAnalyzing] = useState(false);
const [replicating, setReplicating] = useState(false);
const [analysis, setAnalysis] = useState<ViralAnalysis | null>(viralAnalysis ?? null);
useEffect(() => {
setAnalysis(viralAnalysis ?? null);
}, [viralAnalysis, scanItemId]);
async function handleAnalyze() {
setAnalyzing(true);
try {
const res = await fetch("/api/analyze-viral", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanItemId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "爆款分析失敗", message: data.error });
return;
}
setAnalysis(data.analysis);
onUpdate();
} catch {
notify({ type: "error", title: "爆款分析失敗", message: "網路連線異常,請稍後再試" });
} finally {
setAnalyzing(false);
}
}
async function handleReplicate() {
setReplicating(true);
try {
const res = await fetch("/api/replicate-viral", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanItemId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "複製爆款失敗", message: data.error });
return;
}
notify({ type: "success", title: "複製版草稿已生成", href: "/" });
onUpdate();
} catch {
notify({ type: "error", title: "複製爆款失敗", message: "網路連線異常,請稍後再試" });
} finally {
setReplicating(false);
}
}
return (
<div className="mt-3 space-y-3 border-t border-border pt-3">
{mediaUrls.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{mediaUrls.slice(0, 4).map((url) => (
<div
key={url}
className="relative h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="貼文附圖" className="h-full w-full object-cover" />
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Flame className="h-3.5 w-3.5" />
)}
{analyzing ? "分析中…" : analysis ? "重新分析" : "爆款分析"}
</Button>
{analysis && (
<Button size="sm" onClick={handleReplicate} disabled={replicating}>
{replicating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{replicating ? "生成中…" : "複製爆款"}
</Button>
)}
</div>
{analysis && (
<div className="space-y-3 rounded-lg border border-border bg-background p-3.5 text-[13px]">
<div>
<p className="mb-1.5 flex items-center gap-1.5 font-semibold">
<Flame className="h-3.5 w-3.5" />
</p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
{analysis.whyViral.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<p className="font-medium">Hook </p>
<p className="text-muted-foreground">{analysis.hookPattern}</p>
</div>
<div>
<p className="font-medium"></p>
<p className="text-muted-foreground">{analysis.structurePattern}</p>
</div>
<div>
<p className="font-medium"></p>
<p className="text-muted-foreground">{analysis.emotionalTrigger}</p>
</div>
<div>
<p className="font-medium"></p>
<p className="text-muted-foreground">{analysis.timingAngle}</p>
</div>
</div>
<div>
<p className="mb-1.5 flex items-center gap-1.5 font-semibold">
<MessageCircle className="h-3.5 w-3.5" />
</p>
<p className="text-muted-foreground">{analysis.commentInsights.whyPeopleEngage}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{analysis.commentInsights.themes.map((t) => (
<Badge key={t} variant="outline" className="text-[11px]">
{t}
</Badge>
))}
</div>
{analysis.commentInsights.audienceQuestions.length > 0 && (
<p className="mt-2 text-[12px] text-muted-foreground">
{analysis.commentInsights.audienceQuestions.join("、")}
</p>
)}
</div>
{analysis.visualAnalysis.hasImages && (
<div>
<p className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ImageIcon className="h-3.5 w-3.5" />
</p>
<p className="text-muted-foreground">
{analysis.visualAnalysis.layout} · {analysis.visualAnalysis.colorMood}
</p>
<p className="mt-1 text-muted-foreground"> hook{analysis.visualAnalysis.visualHook}</p>
<ul className="mt-2 list-inside list-disc text-muted-foreground">
{analysis.visualAnalysis.replicationTips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
{analysis.visualAnalysis.imageGenPrompt && (
<div className="mt-2 rounded-md bg-muted p-2.5">
<p className="text-[11px] font-medium">AI Prompt</p>
<p className="mt-1 font-mono text-[11px] leading-relaxed text-muted-foreground">
{analysis.visualAnalysis.imageGenPrompt}
</p>
</div>
)}
</div>
)}
<div>
<p className="font-medium"></p>
<p className="text-muted-foreground">{analysis.replicationStrategy}</p>
</div>
</div>
)}
</div>
);
}