626 lines
25 KiB
TypeScript
626 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { ChevronLeft, ChevronRight, Copy, ExternalLink, Loader2, RefreshCw, Radar, ScanSearch, Send, Target, Trash2 } from "lucide-react";
|
||
import { ProductContextForm } from "@/components/product-context-form";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { EmptyState } from "@/components/layout/empty-state";
|
||
import { PageHeader } from "@/components/layout/page-header";
|
||
import { InlineAlert } from "@/components/ui/inline-alert";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { notify } from "@/lib/notifications/store";
|
||
import { useCapabilities } from "@/lib/capabilities/context";
|
||
import { useActionFeedback } from "@/lib/use-action-feedback";
|
||
import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context";
|
||
import { isPlacementGoal } from "@/lib/types/topic-goal";
|
||
import { parseFetchJson, THREADS_MAX_CHARS } from "@/lib/utils";
|
||
|
||
interface OutreachDraft {
|
||
id: string;
|
||
text: string;
|
||
angle?: string | null;
|
||
rationale?: string | null;
|
||
status: string;
|
||
}
|
||
|
||
interface OutreachTarget {
|
||
id: string;
|
||
status: string;
|
||
relevance?: number | null;
|
||
reason?: string | null;
|
||
scanItem: {
|
||
id: string;
|
||
text: string;
|
||
authorName?: string | null;
|
||
permalink?: string | null;
|
||
likeCount?: number | null;
|
||
replyCount?: number | null;
|
||
placementReason?: string | null;
|
||
placementScore?: number | null;
|
||
scan: {
|
||
topic: {
|
||
id: string;
|
||
label: string;
|
||
topicGoal?: string | null;
|
||
productContext?: string | null;
|
||
brief?: string | null;
|
||
};
|
||
scanGoal?: string | null;
|
||
};
|
||
};
|
||
drafts: OutreachDraft[];
|
||
}
|
||
|
||
interface PlacementTopic {
|
||
id: string;
|
||
label: string;
|
||
query: string;
|
||
topicGoal: string;
|
||
scanCount: number;
|
||
latestScan: {
|
||
id: string;
|
||
createdAt: string;
|
||
itemCount: number;
|
||
} | null;
|
||
}
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
export default function OutreachPage() {
|
||
const [targets, setTargets] = useState<OutreachTarget[]>([]);
|
||
const [page, setPage] = useState(1);
|
||
const [total, setTotal] = useState(0);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [selectMode, setSelectMode] = useState(false);
|
||
const [selectedDraftIds, setSelectedDraftIds] = useState<Set<string>>(new Set());
|
||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||
const [placementTopics, setPlacementTopics] = useState<PlacementTopic[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [draftText, setDraftText] = useState<Record<string, string>>({});
|
||
const [productDrafts, setProductDrafts] = useState<Record<string, string>>({});
|
||
const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback();
|
||
const { isReady } = useCapabilities();
|
||
const threadsApiReady = isReady("threadsApi");
|
||
const load = useCallback(async (silent = false) => {
|
||
if (!silent) setLoading(true);
|
||
try {
|
||
const [outreachRes, topicsRes] = await Promise.all([
|
||
fetch(`/api/outreach?page=${page}&limit=${PAGE_SIZE}`),
|
||
fetch("/api/topics"),
|
||
]);
|
||
const [data, topicsData] = await Promise.all([
|
||
parseFetchJson<{ targets?: OutreachTarget[]; total?: number; totalPages?: number; error?: string }>(outreachRes),
|
||
parseFetchJson<{ topics?: PlacementTopic[]; error?: string }>(topicsRes),
|
||
]);
|
||
if (!outreachRes.ok) {
|
||
showError(data.error ?? "無法載入受眾名單", "載入失敗");
|
||
setTargets([]);
|
||
setTotal(0);
|
||
setTotalPages(1);
|
||
return;
|
||
}
|
||
const rows = data.targets ?? [];
|
||
setTargets(rows);
|
||
setTotal(data.total ?? rows.length);
|
||
setTotalPages(data.totalPages ?? 1);
|
||
if (page > (data.totalPages ?? 1)) setPage(Math.max(1, data.totalPages ?? 1));
|
||
setPlacementTopics((topicsData.topics ?? []).filter((t) => isPlacementGoal(t.topicGoal)));
|
||
setDraftText(
|
||
Object.fromEntries(
|
||
rows.flatMap((target: OutreachTarget) => target.drafts.map((draft) => [draft.id, draft.text]))
|
||
)
|
||
);
|
||
setProductDrafts((prev) => {
|
||
const next = { ...prev };
|
||
for (const target of rows as OutreachTarget[]) {
|
||
const topic = target.scanItem.scan.topic;
|
||
if (next[topic.id] === undefined) {
|
||
next[topic.id] = topic.productContext ?? "";
|
||
}
|
||
}
|
||
return next;
|
||
});
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "載入失敗");
|
||
setTargets([]);
|
||
setTotal(0);
|
||
setTotalPages(1);
|
||
} finally {
|
||
if (!silent) setLoading(false);
|
||
}
|
||
}, [page, showError]);
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
setSelectedDraftIds(new Set());
|
||
}, [page]);
|
||
|
||
async function saveDraft(draftId: string) {
|
||
setBusy(`save-${draftId}`);
|
||
try {
|
||
const res = await fetch(`/api/outreach/drafts/${draftId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text: draftText[draftId], status: "EDITED" }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
showError(data.error ?? "無法儲存草稿", "儲存失敗");
|
||
return;
|
||
}
|
||
showSuccess("草稿已儲存");
|
||
load(true);
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "儲存失敗");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
async function deleteDraft(draft: OutreachDraft) {
|
||
if (!confirm("確定刪除這則留言草稿?刪除後無法復原。")) return;
|
||
setBusy(`delete-${draft.id}`);
|
||
try {
|
||
const res = await fetch(`/api/outreach/drafts/${draft.id}`, { method: "DELETE" });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
showError(data.error ?? "無法刪除留言草稿", "刪除失敗");
|
||
return;
|
||
}
|
||
setDraftText((prev) => {
|
||
const next = { ...prev };
|
||
delete next[draft.id];
|
||
return next;
|
||
});
|
||
showSuccess("留言草稿已刪除");
|
||
load(true);
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "刪除失敗");
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
function toggleDraftSelection(id: string) {
|
||
setSelectedDraftIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function exitSelectMode() {
|
||
setSelectMode(false);
|
||
setSelectedDraftIds(new Set());
|
||
}
|
||
|
||
async function deleteSelectedDrafts() {
|
||
const ids = [...selectedDraftIds];
|
||
if (ids.length === 0) return;
|
||
if (!confirm(`確定刪除選取的 ${ids.length} 則留言草稿?刪除後無法復原。`)) return;
|
||
setBatchDeleting(true);
|
||
try {
|
||
const res = await fetch("/api/outreach/drafts", {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ids }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
showError(data.error ?? "無法批次刪除留言草稿", "批次刪除失敗");
|
||
return;
|
||
}
|
||
showSuccess(`已刪除 ${data.deleted ?? ids.length} 則留言草稿`);
|
||
exitSelectMode();
|
||
await load(true);
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "批次刪除失敗");
|
||
} finally {
|
||
setBatchDeleting(false);
|
||
}
|
||
}
|
||
|
||
async function copyText(text: string) {
|
||
await navigator.clipboard.writeText(text);
|
||
showSuccess("已複製留言");
|
||
}
|
||
|
||
const placementTopicId = targets.find((t) =>
|
||
isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal)
|
||
)?.scanItem.scan.topic.id;
|
||
|
||
async function saveProductContext(topicId: string) {
|
||
setBusy(`product-${topicId}`);
|
||
try {
|
||
const res = await fetch("/api/topics", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ id: topicId, productContext: productDrafts[topicId] ?? "" }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
showError(data.error ?? "無法儲存品牌與產品", "儲存失敗");
|
||
return false;
|
||
}
|
||
showSuccess("品牌與產品已儲存");
|
||
load(true);
|
||
return true;
|
||
} catch {
|
||
showError("網路連線異常,請稍後再試", "儲存失敗");
|
||
return false;
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
async function regenerateTarget(target: OutreachTarget) {
|
||
const topicId = target.scanItem.scan.topic.id;
|
||
const productContext = productDrafts[topicId] ?? "";
|
||
if (!hasProductContext(productContext)) {
|
||
showWarning("在上方區塊填寫品牌與產品後,再重新生成。", "請先填寫品牌與產品");
|
||
return;
|
||
}
|
||
|
||
setBusy(`regen-${target.id}`);
|
||
try {
|
||
const res = await fetch("/api/outreach/generate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
scanItemId: target.scanItem.id,
|
||
productContext,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
notify({ type: "error", title: "重新生成失敗", message: data.error });
|
||
return;
|
||
}
|
||
notify({ type: "success", title: "已重新生成回覆草稿" });
|
||
load(true);
|
||
} catch {
|
||
notify({ type: "error", title: "重新生成失敗", message: "網路連線異常,請稍後再試" });
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
async function publishDraft(draft: OutreachDraft) {
|
||
setBusy(`publish-${draft.id}`);
|
||
try {
|
||
const res = await fetch(`/api/outreach/drafts/${draft.id}/publish`, { method: "POST" });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
notify({ type: "error", title: "發布留言失敗", message: data.error });
|
||
return;
|
||
}
|
||
notify({ type: "success", title: "留言已發布到 Threads" });
|
||
load(true);
|
||
} catch {
|
||
notify({ type: "error", title: "發布留言失敗", message: "網路連線異常,請稍後再試" });
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
const hasPlacementTargets = targets.some((t) =>
|
||
isPlacementGoal(t.scanItem.scan.scanGoal ?? t.scanItem.scan.topic.topicGoal)
|
||
);
|
||
const deletableDraftIds = targets.flatMap((target) =>
|
||
target.drafts.filter((draft) => draft.status !== "PUBLISHED").map((draft) => draft.id)
|
||
);
|
||
const allPageDraftsSelected =
|
||
deletableDraftIds.length > 0 && deletableDraftIds.every((id) => selectedDraftIds.has(id));
|
||
|
||
function toggleAllPageDrafts() {
|
||
setSelectedDraftIds((prev) => {
|
||
const next = new Set(prev);
|
||
if (allPageDraftsSelected) deletableDraftIds.forEach((id) => next.delete(id));
|
||
else deletableDraftIds.forEach((id) => next.add(id));
|
||
return next;
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
eyebrow="02 / FIND YOUR TA"
|
||
title="找出正在需要你的人"
|
||
description="從 Threads 訊號辨識高意向受眾,再套入你的人設與產品脈絡,產出自然、不硬銷的開場話術。"
|
||
action={<Button asChild size="lg"><Link href="/scans?mode=placement"><Radar className="h-4 w-4" />挖掘新受眾</Link></Button>}
|
||
/>
|
||
|
||
{feedback && (
|
||
<InlineAlert
|
||
type={feedback.type}
|
||
title={feedback.title}
|
||
message={feedback.message}
|
||
onDismiss={clearFeedback}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
|
||
{!loading && targets.length > 0 && (
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||
<p className="text-xs text-muted-foreground">共 {total} 個目標 · 第 {page}/{totalPages} 頁</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectMode ? (
|
||
<>
|
||
<Button size="sm" variant="outline" onClick={toggleAllPageDrafts} disabled={deletableDraftIds.length === 0}>
|
||
{allPageDraftsSelected ? "取消全選" : "全選本頁"}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={deleteSelectedDrafts} disabled={selectedDraftIds.size === 0 || batchDeleting}>
|
||
{batchDeleting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
||
刪除選取({selectedDraftIds.size})
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={exitSelectMode}>完成</Button>
|
||
</>
|
||
) : (
|
||
<Button size="sm" variant="outline" onClick={() => setSelectMode(true)}>
|
||
批次管理
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="space-y-4">
|
||
{[0, 1, 2].map((i) => <div key={i} className="skeleton h-56 animate-pulse" />)}
|
||
</div>
|
||
) : targets.length === 0 ? (
|
||
placementTopics.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
|
||
你已有 {placementTopics.length} 個找 TA 主題。到海巡成果頁點「獲客留言」,生成的對象會出現在這裡。
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{placementTopics.map((topic) => (
|
||
<Card key={topic.id}>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="truncate">{topic.label}</CardTitle>
|
||
<CardDescription className="truncate">{topic.query}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{topic.latestScan ? (
|
||
<p className="text-[13px] text-muted-foreground">
|
||
最近海巡 {new Date(topic.latestScan.createdAt).toLocaleDateString("zh-TW", { month: "short", day: "numeric" })}
|
||
{" · "}
|
||
{topic.latestScan.itemCount} 篇
|
||
</p>
|
||
) : (
|
||
<p className="text-[13px] text-muted-foreground">尚未海巡</p>
|
||
)}
|
||
<div className="flex flex-wrap gap-2">
|
||
{topic.latestScan ? (
|
||
<Button size="sm" asChild>
|
||
<Link href={`/scans/${topic.id}/results`}>
|
||
<ScanSearch className="h-3.5 w-3.5" />
|
||
前往生成留言
|
||
</Link>
|
||
</Button>
|
||
) : (
|
||
<Button size="sm" variant="outline" asChild>
|
||
<Link href={`/scans/${topic.id}`}>
|
||
<ScanSearch className="h-3.5 w-3.5" />
|
||
先完成海巡
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<div className="flex justify-center pt-2">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link href="/scans?mode=placement"><Radar className="h-4 w-4" />新增更多主題</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<EmptyState
|
||
icon={Target}
|
||
title="還沒有高意向受眾"
|
||
description="建立找 TA 主題並海巡後,AI 會從中辨識高意向受眾並生成開場話術。"
|
||
action={<Button asChild><Link href="/scans?mode=placement"><Radar className="h-4 w-4" />開始挖掘</Link></Button>}
|
||
/>
|
||
)
|
||
) : (
|
||
<div className="space-y-4">
|
||
{hasPlacementTargets && placementTopicId && (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">品牌與產品</CardTitle>
|
||
<CardDescription>
|
||
調整後可重新生成,讓留言更自然帶到你的品牌(不會硬銷)。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<ProductContextForm
|
||
compact
|
||
value={productDrafts[placementTopicId] ?? ""}
|
||
onChange={(value) =>
|
||
setProductDrafts((prev) => ({ ...prev, [placementTopicId]: value }))
|
||
}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy === `product-${placementTopicId}`}
|
||
onClick={() => saveProductContext(placementTopicId)}
|
||
>
|
||
{busy === `product-${placementTopicId}` ? "儲存中…" : "儲存品牌與產品"}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{targets.map((target) => {
|
||
const isPlacement = isPlacementGoal(
|
||
target.scanItem.scan.scanGoal ?? target.scanItem.scan.topic.topicGoal
|
||
);
|
||
const productSummary = summarizeProductContext(
|
||
productDrafts[target.scanItem.scan.topic.id] ?? target.scanItem.scan.topic.productContext
|
||
);
|
||
|
||
return (
|
||
<Card key={target.id}>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<CardTitle>
|
||
@{target.scanItem.authorName ?? "匿名"} · {target.scanItem.scan.topic.label}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{target.scanItem.likeCount ?? 0} 讚 / {target.scanItem.replyCount ?? 0} 留言
|
||
</CardDescription>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
<Badge variant={target.status === "LOW_RELEVANCE" ? "warning" : "success"}>
|
||
相關度 {Math.round((target.relevance ?? 0) * 100)}%
|
||
</Badge>
|
||
<Badge variant="outline">{target.status}</Badge>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{isPlacement && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy === `regen-${target.id}`}
|
||
onClick={() => regenerateTarget(target)}
|
||
>
|
||
{busy === `regen-${target.id}` ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
)}
|
||
重新生成
|
||
</Button>
|
||
)}
|
||
{target.scanItem.permalink && (
|
||
<Button size="sm" variant="outline" asChild>
|
||
<a href={target.scanItem.permalink} target="_blank" rel="noreferrer">
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
原貼文
|
||
</a>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||
<p className="text-[13px] leading-relaxed">{target.scanItem.text}</p>
|
||
{productSummary && (
|
||
<p className="mt-2 text-xs text-muted-foreground">置入參考:{productSummary}</p>
|
||
)}
|
||
{(target.scanItem.placementReason || target.reason) && (
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
{target.scanItem.placementReason ?? target.reason}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{target.drafts.map((draft) => {
|
||
const value = draftText[draft.id] ?? draft.text;
|
||
return (
|
||
<div key={draft.id} className="relative space-y-2 rounded-lg border border-border p-3">
|
||
{selectMode && draft.status !== "PUBLISHED" && (
|
||
<button
|
||
type="button"
|
||
aria-label="選取留言草稿"
|
||
onClick={() => toggleDraftSelection(draft.id)}
|
||
className={`absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded border text-xs ${
|
||
selectedDraftIds.has(draft.id)
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background"
|
||
}`}
|
||
>
|
||
{selectedDraftIds.has(draft.id) ? "✓" : ""}
|
||
</button>
|
||
)}
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className={`flex flex-wrap gap-1.5 ${selectMode ? "pr-8" : ""}`}>
|
||
<Badge variant={draft.status === "PUBLISHED" ? "success" : "warning"}>{draft.status}</Badge>
|
||
{draft.angle && <Badge variant="outline">{draft.angle}</Badge>}
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">{value.length}/{THREADS_MAX_CHARS}</span>
|
||
</div>
|
||
<Textarea
|
||
value={value}
|
||
rows={3}
|
||
onChange={(e) => setDraftText((prev) => ({ ...prev, [draft.id]: e.target.value }))}
|
||
/>
|
||
{draft.rationale && <p className="text-xs text-muted-foreground">{draft.rationale}</p>}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => saveDraft(draft.id)} disabled={busy === `save-${draft.id}`}>
|
||
儲存
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => copyText(value)}>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
複製
|
||
</Button>
|
||
{draft.status !== "PUBLISHED" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => deleteDraft(draft)}
|
||
disabled={busy === `delete-${draft.id}`}
|
||
>
|
||
{busy === `delete-${draft.id}` ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
)}
|
||
刪除
|
||
</Button>
|
||
)}
|
||
{draft.status !== "PUBLISHED" && (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => publishDraft(draft)}
|
||
disabled={busy === `publish-${draft.id}` || !threadsApiReady}
|
||
title={!threadsApiReady ? "需先在連線設定綁定 Threads API" : undefined}
|
||
>
|
||
{busy === `publish-${draft.id}` ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Send className="h-3.5 w-3.5" />
|
||
)}
|
||
發布留言
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-center gap-3 pt-2">
|
||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))}>
|
||
<ChevronLeft className="h-4 w-4" />上一頁
|
||
</Button>
|
||
<span className="text-xs text-muted-foreground">第 {page} / {totalPages} 頁</span>
|
||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => setPage((value) => Math.min(totalPages, value + 1))}>
|
||
下一頁<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|