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

325 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { ChevronLeft, ChevronRight, Download, Puzzle, Radar, Sparkles, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PageHeader } from "@/components/layout/page-header";
import { DraftCard } from "@/components/draft-card";
import { notify } from "@/lib/notifications/store";
interface MatrixRow {
id: string;
sortOrder: number | null;
searchTag: string | null;
angle: string | null;
hook: string | null;
text: string;
referenceNotes: string | null;
sources: string | null;
imageBrief?: string | null;
imagePath?: string | null;
imagePaths?: string | null;
draftType?: string | null;
rationale?: string | null;
status: string;
createdAt: string;
}
interface AccountStatus {
id: string;
sessionSynced?: boolean;
browserConnected?: boolean;
}
const PAGE_SIZE = 10;
export default function MatrixPage() {
const [rows, setRows] = useState<MatrixRow[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [sessionSynced, setSessionSynced] = useState(false);
const [hasAccount, setHasAccount] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const load = useCallback(async (targetPage = page) => {
setLoading(true);
try {
const [matrixRes, accountsRes] = await Promise.all([
fetch(`/api/matrix?page=${targetPage}&limit=${PAGE_SIZE}`),
fetch("/api/accounts"),
]);
const [data, accountData] = await Promise.all([
matrixRes.json().catch(() => ({})),
accountsRes.json().catch(() => ({})),
]);
if (!matrixRes.ok) {
notify({
type: "error",
title: "載入草稿失敗",
message: data.error ?? "請稍後再試",
});
setRows([]);
setTotal(0);
setTotalPages(1);
return;
}
const accounts = (accountData.accounts ?? []) as AccountStatus[];
const active = accounts.find((account) => account.id === accountData.activeAccountId) ?? accounts[0];
const fetchedRows = data.rows ?? [];
const fetchedTotal = data.total ?? 0;
const fetchedTotalPages = data.totalPages ?? 1;
setRows(fetchedRows);
setTotal(fetchedTotal);
setTotalPages(fetchedTotalPages);
setHasAccount(Boolean(active));
setSessionSynced(Boolean(active?.sessionSynced || active?.browserConnected));
if (fetchedRows.length === 0 && targetPage > 1) {
setPage(1);
} else if (targetPage > fetchedTotalPages) {
setPage(fetchedTotalPages);
}
} catch {
notify({
type: "error",
title: "載入草稿失敗",
message: "網路連線異常,請稍後再試",
});
setRows([]);
setTotal(0);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
void load(page);
}, [page, load]);
useEffect(() => {
setSelectedIds(new Set());
}, [page]);
function goToPage(p: number) {
if (p < 1 || p > totalPages || p === page) return;
setPage(p);
}
function exitSelectMode() {
setSelectMode(false);
setSelectedIds(new Set());
}
function toggleDraftSelection(id: string, selected: boolean) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleSelectAllOnPage() {
const pageIds = rows.map((row) => row.id);
const allSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
setSelectedIds((prev) => {
const next = new Set(prev);
if (allSelected) {
for (const id of pageIds) next.delete(id);
} else {
for (const id of pageIds) next.add(id);
}
return next;
});
}
async function handleBulkDelete() {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setBulkDeleting(true);
try {
const res = await fetch("/api/drafts", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
const data = await res.json();
if (!res.ok) {
notify({
type: "error",
title: "批量刪除失敗",
message: data.error ?? "請稍後再試",
});
return;
}
notify({
type: "success",
title: "已刪除選取的草稿",
message: `共刪除 ${data.deleted ?? ids.length}`,
});
exitSelectMode();
await load(page);
} finally {
setBulkDeleting(false);
}
}
const pageIds = rows.map((row) => row.id);
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
const selectedCount = selectedIds.size;
return (
<div>
<PageHeader
eyebrow="01 / COPY NINJA"
title="拷貝忍者"
description="海巡 Threads 熱門素材,依你的風格產出可直接發布的貼文草稿。"
action={
<Button asChild size="lg">
<Link href="/scans?mode=viral"><Sparkles className="h-4 w-4" /></Link>
</Button>
}
/>
<div className="mb-6 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="mt-1 text-sm text-muted-foreground">
{total > 0 ? `${total} 篇 · 第 ${page}/${totalPages}` : "完成第一個風格任務後會出現在這裡"}
</p>
</div>
{total > 0 && (
<div className="flex flex-wrap items-center gap-2">
{selectMode ? (
<>
<Button variant="outline" size="sm" onClick={toggleSelectAllOnPage}>
{allOnPageSelected ? "取消全選" : "全選本頁"}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmBulkDelete(true)}
disabled={selectedCount === 0 || bulkDeleting}
>
<Trash2 className="h-4 w-4" />
{bulkDeleting ? "刪除中…" : `刪除${selectedCount > 0 ? ` (${selectedCount})` : ""}`}
</Button>
<Button variant="ghost" size="sm" onClick={exitSelectMode} disabled={bulkDeleting}>
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => setSelectMode(true)}>
<Trash2 className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" asChild>
<a href="/api/matrix/export" download><Download className="h-4 w-4" /></a>
</Button>
</>
)}
</div>
)}
</div>
{loading ? (
<div className="space-y-6">
{[0, 1, 2].map((i) => (
<div key={i} className="skeleton h-48 animate-pulse" />
))}
</div>
) : rows.length === 0 ? (
<Card className="overflow-hidden border-dashed">
<CardContent className="flex min-h-64 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{sessionSynced ? <Radar className="h-6 w-6" /> : <Puzzle className="h-6 w-6" />}
</div>
<h3 className="text-lg font-semibold">
{sessionSynced ? "Extension 已同步,可以開始海巡" : hasAccount ? "先同步你的 Threads Extension" : "先建立經營帳號"}
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
{sessionSynced
? "目前帳號的 Threads Session 已就緒。新增海巡任務,設定主題後開始分析與抓取。"
: hasAccount
? "登入 Threads 後按 Extension 同步;完成後這裡會自動辨識帳號狀態。"
: "建立帳號並填好人設,再透過 Extension 同步 Threads Session。"}
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{sessionSynced ? (
<Button asChild><Link href="/scans?mode=viral"></Link></Button>
) : hasAccount ? (
<Button asChild><Link href="/connections"> Extension</Link></Button>
) : (
<Button asChild><Link href="/connections"></Link></Button>
)}
</div>
</CardContent>
</Card>
) : (
<>
<div className="divide-y divide-border">
{rows.map((row, index) => (
<DraftCard
key={row.id}
draft={row}
onUpdate={() => load(page)}
index={index}
selectable={selectMode}
selected={selectedIds.has(row.id)}
onSelectedChange={(selected) => toggleDraftSelection(row.id, selected)}
/>
))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => goToPage(page - 1)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => goToPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
<ConfirmDialog
open={confirmBulkDelete}
onOpenChange={setConfirmBulkDelete}
title="批量刪除草稿"
description={`確定要刪除選取的 ${selectedCount} 篇草稿?此操作無法復原。`}
confirmText="刪除"
danger
onConfirm={handleBulkDelete}
/>
</div>
);
}