haixunMaster/app/(dashboard)/scans/page.tsx

202 lines
7.0 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 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>
);
}