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>
|
||
);
|
||
}
|