325 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|