202 lines
7.0 KiB
TypeScript
202 lines
7.0 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import Link from "next/link";
|
|||
|
|
import { useSearchParams } from "next/navigation";
|
|||
|
|
import { useEffect, useMemo, useState } from "react";
|
|||
|
|
import { Plus, ScanSearch, Settings2 } from "lucide-react";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { TopicFormDialog } from "@/components/inspiration/topic-form-dialog";
|
|||
|
|
import { EmptyState } from "@/components/layout/empty-state";
|
|||
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|||
|
|
import { notify } from "@/lib/notifications/store";
|
|||
|
|
import {
|
|||
|
|
TOPIC_GOAL_LABELS,
|
|||
|
|
isPlacementGoal,
|
|||
|
|
parseTopicGoal,
|
|||
|
|
type TopicGoal,
|
|||
|
|
} from "@/lib/types/topic-goal";
|
|||
|
|
import { parseFetchJson } from "@/lib/utils";
|
|||
|
|
|
|||
|
|
interface TopicListItem {
|
|||
|
|
id: string;
|
|||
|
|
label: string;
|
|||
|
|
query: string;
|
|||
|
|
topicGoal: string;
|
|||
|
|
scanCount: number;
|
|||
|
|
latestScan: {
|
|||
|
|
id: string;
|
|||
|
|
createdAt: string;
|
|||
|
|
itemCount: number;
|
|||
|
|
} | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function ScansPage() {
|
|||
|
|
const searchParams = useSearchParams();
|
|||
|
|
const [topics, setTopics] = useState<TopicListItem[]>([]);
|
|||
|
|
const mode: TopicGoal = parseTopicGoal(searchParams.get("mode"));
|
|||
|
|
const [search, setSearch] = useState("");
|
|||
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const res = await fetch("/api/topics");
|
|||
|
|
const data = await parseFetchJson<{ topics?: TopicListItem[]; error?: string }>(res);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
notify({ type: "error", title: "載入主題失敗", message: data.error });
|
|||
|
|
setTopics([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setTopics(data.topics ?? []);
|
|||
|
|
} catch (err) {
|
|||
|
|
const message = err instanceof Error ? err.message : "載入失敗";
|
|||
|
|
notify({ type: "error", title: "載入找靈感失敗", message });
|
|||
|
|
setTopics([]);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
load();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const modeTopics = useMemo(
|
|||
|
|
() =>
|
|||
|
|
topics.filter((t) =>
|
|||
|
|
isPlacementGoal(t.topicGoal) ? mode === "placement" : mode === "viral"
|
|||
|
|
),
|
|||
|
|
[topics, mode]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const filteredTopics = useMemo(() => {
|
|||
|
|
const q = search.trim().toLowerCase();
|
|||
|
|
if (!q) return modeTopics;
|
|||
|
|
return modeTopics.filter(
|
|||
|
|
(t) => t.label.toLowerCase().includes(q) || t.query.toLowerCase().includes(q)
|
|||
|
|
);
|
|||
|
|
}, [modeTopics, search]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<PageHeader
|
|||
|
|
eyebrow={mode === "viral" ? "HAIXUN" : "FIND YOUR TA"}
|
|||
|
|
title={mode === "viral" ? "建立主題海巡任務" : "建立高意向受眾任務"}
|
|||
|
|
description={mode === "viral" ? "設定要模仿的內容主題,Extension 會使用你的 Threads session 進行抓取與分析。" : "設定產品與受眾關鍵字,找出有需求訊號的人並生成人設置入話術。"}
|
|||
|
|
action={
|
|||
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|||
|
|
<Plus className="h-3.5 w-3.5" />
|
|||
|
|
新增任務
|
|||
|
|
</Button>
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="mb-5 space-y-3">
|
|||
|
|
<Input
|
|||
|
|
value={search}
|
|||
|
|
onChange={(e) => setSearch(e.target.value)}
|
|||
|
|
placeholder="搜尋主題名稱或種子關鍵字…"
|
|||
|
|
className="max-w-sm"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<TopicFormDialog
|
|||
|
|
open={createOpen}
|
|||
|
|
onOpenChange={setCreateOpen}
|
|||
|
|
defaultGoal={mode}
|
|||
|
|
onCreated={load}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="py-16 text-center text-[13px] text-muted-foreground">載入中…</div>
|
|||
|
|
) : filteredTopics.length === 0 ? (
|
|||
|
|
<EmptyState
|
|||
|
|
icon={ScanSearch}
|
|||
|
|
title={modeTopics.length === 0 ? "尚無此模式的主題" : "找不到符合的主題"}
|
|||
|
|
description={
|
|||
|
|
modeTopics.length === 0
|
|||
|
|
? "按「新增任務」開始建立第一個任務"
|
|||
|
|
: "試試其他關鍵字,或清除搜尋"
|
|||
|
|
}
|
|||
|
|
action={
|
|||
|
|
modeTopics.length === 0 ? (
|
|||
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|||
|
|
新增任務
|
|||
|
|
</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button size="sm" variant="outline" onClick={() => setSearch("")}>
|
|||
|
|
清除搜尋
|
|||
|
|
</Button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|||
|
|
{filteredTopics.map((topic, i) => {
|
|||
|
|
const goal = isPlacementGoal(topic.topicGoal) ? "placement" : "viral";
|
|||
|
|
const hasScans = topic.scanCount > 0;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Card
|
|||
|
|
key={topic.id}
|
|||
|
|
className="animate-fade-in-up"
|
|||
|
|
style={{ animationDelay: `${i * 40}ms` }}
|
|||
|
|
>
|
|||
|
|
<CardHeader className="pb-3">
|
|||
|
|
<div className="flex items-start justify-between gap-2">
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<CardTitle className="truncate">{topic.label}</CardTitle>
|
|||
|
|
<CardDescription className="mt-1 truncate">{topic.query}</CardDescription>
|
|||
|
|
</div>
|
|||
|
|
<Badge variant={goal === "placement" ? "secondary" : "default"}>
|
|||
|
|
{TOPIC_GOAL_LABELS[goal]}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-3">
|
|||
|
|
{topic.latestScan ? (
|
|||
|
|
<p className="text-[13px] text-muted-foreground">
|
|||
|
|
最近海巡 {new Date(topic.latestScan.createdAt).toLocaleString("zh-TW")}
|
|||
|
|
{" · "}
|
|||
|
|
{topic.latestScan.itemCount} 篇
|
|||
|
|
{" · "}
|
|||
|
|
共 {topic.scanCount} 次海巡
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
<p className="text-[13px] text-muted-foreground">尚未海巡</p>
|
|||
|
|
)}
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
<Button size="sm" variant="outline" asChild>
|
|||
|
|
<Link href={`/scans/${topic.id}`}>
|
|||
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|||
|
|
設定主題
|
|||
|
|
</Link>
|
|||
|
|
</Button>
|
|||
|
|
{hasScans ? (
|
|||
|
|
<Button size="sm" asChild>
|
|||
|
|
<Link href={`/scans/${topic.id}/results`}>
|
|||
|
|
<ScanSearch className="h-3.5 w-3.5" />
|
|||
|
|
查看海巡
|
|||
|
|
</Link>
|
|||
|
|
</Button>
|
|||
|
|
) : (
|
|||
|
|
<Button size="sm" disabled title="請先完成海巡">
|
|||
|
|
<ScanSearch className="h-3.5 w-3.5" />
|
|||
|
|
查看海巡
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|