haixunMaster/components/draft-card.tsx

855 lines
30 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import { useEffect, useRef, useState } from "react";
import {
ExternalLink,
ImageIcon,
Loader2,
Pencil,
Send,
ShieldCheck,
Sparkles,
Upload,
Wand2,
X,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { notify } from "@/lib/notifications/store";
import { useCapabilities } from "@/lib/capabilities/context";
import { MAX_DRAFT_IMAGES } from "@/lib/drafts/constants";
import { THREADS_MAX_CHARS, cn } from "@/lib/utils";
export interface DraftData {
id: string;
text: string;
angle?: string | null;
hook?: string | null;
imageBrief?: string | null;
imagePath?: string | null;
imagePaths?: string | null;
draftType?: string | null;
rationale?: string | null;
sources?: string | null;
status: string;
factCheckResult?: string | null;
createdAt: string;
}
interface DraftCardProps {
draft: DraftData;
onUpdate: () => void;
index?: number;
selectable?: boolean;
selected?: boolean;
onSelectedChange?: (selected: boolean) => void;
}
type OptimizeMode = "polish" | "hook" | "shorter" | "engaging" | "custom";
const OPTIMIZE_MODES: { value: OptimizeMode; label: string }[] = [
{ value: "polish", label: "整體潤飾" },
{ value: "hook", label: "強化開頭" },
{ value: "shorter", label: "精簡篇幅" },
{ value: "engaging", label: "提升互動" },
{ value: "custom", label: "自訂指令" },
];
const statusLabels: Record<string, { label: string; variant: "warning" | "secondary" | "success" }> = {
PENDING: { label: "待審核", variant: "warning" },
EDITED: { label: "已編輯", variant: "secondary" },
APPROVED: { label: "已核准", variant: "success" },
};
interface FactCheckData {
isKnowledgeContent: boolean;
passed: boolean;
summary: string;
issues: string[];
verifiedPoints: string[];
sources: Array<{ title: string; link: string }>;
searchProviderLabel?: string;
}
function parseFactCheckResult(raw: string | null | undefined): FactCheckData | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && "isKnowledgeContent" in parsed) {
return parsed as FactCheckData;
}
} catch {
// ignore
}
return null;
}
function parseDraftImagePaths(source: Pick<DraftData, "imagePath" | "imagePaths">): string[] {
if (source.imagePaths) {
try {
const parsed = JSON.parse(source.imagePaths) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === "string");
}
} catch {
// Fall back to the legacy single-image field.
}
}
return source.imagePath ? [source.imagePath] : [];
}
export function DraftCard({
draft,
onUpdate,
index = 0,
selectable = false,
selected = false,
onSelectedChange,
}: DraftCardProps) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(draft.text);
const [loading, setLoading] = useState<string | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [showOptimize, setShowOptimize] = useState(false);
const [optimizeMode, setOptimizeMode] = useState<OptimizeMode>("polish");
const [customInstruction, setCustomInstruction] = useState("");
const [optimizeSummary, setOptimizeSummary] = useState<string | null>(null);
const [previousText, setPreviousText] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { isReady } = useCapabilities();
const canPublish = isReady("publish");
const [imagePaths, setImagePaths] = useState<string[]>(() => parseDraftImagePaths(draft));
const [confirmDialog, setConfirmDialog] = useState<{
title: string;
description?: string;
confirmText?: string;
danger?: boolean;
onConfirm: () => void | Promise<void>;
} | null>(null);
useEffect(() => {
setImagePaths(
parseDraftImagePaths({ imagePath: draft.imagePath, imagePaths: draft.imagePaths })
);
}, [draft.imagePath, draft.imagePaths]);
const [factCheck, setFactCheck] = useState<FactCheckData | null>(() =>
parseFactCheckResult(draft.factCheckResult)
);
useEffect(() => {
setFactCheck(parseFactCheckResult(draft.factCheckResult));
}, [draft.factCheckResult]);
const sources: string[] = (() => {
if (!draft.sources) return [];
try {
const parsed = JSON.parse(draft.sources);
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
} catch {
return [];
}
})();
const charCount = text.length;
const overLimit = charCount > THREADS_MAX_CHARS;
const statusInfo = statusLabels[draft.status] ?? { label: draft.status, variant: "secondary" as const };
const angleInitial = (draft.angle ?? "稿").charAt(0);
function imagePreviewUrl(imagePath: string) {
return `/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`;
}
async function handleUploadImages(files: File[]) {
if (files.length === 0) return;
if (imagePaths.length + files.length > MAX_DRAFT_IMAGES) {
notify({
type: "error",
title: "超過配圖上限",
message: `每篇草稿最多 ${MAX_DRAFT_IMAGES} 張配圖`,
});
return;
}
setLoading("upload-image");
const formData = new FormData();
for (const file of files) formData.append("file", file);
try {
const res = await fetch(`/api/drafts/${draft.id}/image`, {
method: "POST",
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "上傳失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
notify({
type: "success",
title: "配圖已上傳",
message: files.length > 1 ? `已新增 ${files.length} 張圖片` : undefined,
});
onUpdate();
} catch {
notify({ type: "error", title: "上傳失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleGenerateImage() {
setLoading("gen-image");
try {
const res = await fetch(`/api/drafts/${draft.id}/generate-image`, {
method: "POST",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "AI 生圖失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? parseDraftImagePaths(data.draft ?? draft));
notify({ type: "success", title: "AI 配圖已生成" });
onUpdate();
} catch {
notify({ type: "error", title: "AI 生圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleRemoveImage(imagePath: string) {
setLoading("remove-image");
try {
const res = await fetch(
`/api/drafts/${draft.id}/image?p=${encodeURIComponent(imagePath)}`,
{ method: "DELETE" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "移除配圖失敗", message: data.error });
return;
}
setImagePaths(data.imagePaths ?? []);
onUpdate();
} catch {
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleClearImages() {
setConfirmDialog({
title: "移除全部配圖",
description: "確定要移除這篇草稿的全部配圖?",
confirmText: "移除",
danger: true,
onConfirm: async () => {
setLoading("remove-image");
try {
const res = await fetch(`/api/drafts/${draft.id}/image`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "移除配圖失敗", message: data.error });
return;
}
setImagePaths([]);
onUpdate();
} catch {
notify({ type: "error", title: "移除配圖失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
async function handleSave() {
setLoading("save");
try {
const res = await fetch(`/api/drafts/${draft.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, status: "EDITED" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "儲存失敗", message: data.error });
return;
}
setEditing(false);
setOptimizeSummary(null);
setPreviousText(null);
onUpdate();
} catch {
notify({ type: "error", title: "儲存失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handleOptimize(apply = false) {
setOptimizing(true);
setOptimizeSummary(null);
try {
const res = await fetch("/api/optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draftId: draft.id,
text,
mode: optimizeMode,
instruction: optimizeMode === "custom" ? customInstruction : undefined,
save: apply,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "AI 優化失敗", message: data.error });
return;
}
if (apply) {
setText(data.text);
setEditing(false);
setShowOptimize(false);
setPreviousText(null);
setOptimizeSummary(data.summary);
onUpdate();
return;
}
setPreviousText(text);
setText(data.text);
setEditing(true);
setShowOptimize(false);
setOptimizeSummary(data.summary);
} catch {
notify({ type: "error", title: "AI 優化失敗", message: "網路連線異常,請稍後再試" });
} finally {
setOptimizing(false);
}
}
function handleUndoOptimize() {
if (previousText) {
setText(previousText);
setPreviousText(null);
setOptimizeSummary(null);
}
}
async function handleFactCheck() {
setLoading("factcheck");
setFactCheck(null);
try {
const res = await fetch("/api/fact-check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draftId: draft.id, text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
notify({ type: "error", title: "查證失敗", message: data.error });
return;
}
setFactCheck(data.factCheck);
const fc = data.factCheck;
if (fc.isKnowledgeContent) {
notify({
type: fc.passed ? "success" : "warning",
title: fc.passed ? "知識查證通過" : "知識查證未通過",
message: fc.summary,
});
} else {
notify({ type: "info", title: "非知識型內容", message: fc.summary });
}
onUpdate();
} catch {
notify({ type: "error", title: "查證失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
}
async function handlePublish() {
setConfirmDialog({
title: "發布到 Threads",
description: "確定要發布這篇貼文到 Threads知識型內容會先經網路搜尋查證。",
confirmText: "發布",
onConfirm: async () => {
setLoading("publish");
try {
const res = await fetch("/api/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draftId: draft.id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.factCheck) setFactCheck(data.factCheck);
notify({
type: "error",
title: res.status === 422 ? "知識型內容未通過查證" : "發布失敗",
message: data.factCheck?.issues?.join("") ?? data.error,
href: data.debugRunId ? `/debug` : undefined,
});
return;
}
if (data.factCheck) setFactCheck(data.factCheck);
notify({
type: "success",
title: "已發布到你的 Threads",
message: [
data.method === "api" ? "已透過官方 API 發布。" : "貼文已送出。",
data.warning,
"草稿已移至已發布。",
]
.filter(Boolean)
.join(" "),
href: "/published",
});
onUpdate();
} catch {
notify({ type: "error", title: "發布失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
async function handleReject() {
setConfirmDialog({
title: "拒絕草稿",
description: "確定要拒絕這篇草稿?此操作無法復原。",
confirmText: "拒絕",
danger: true,
onConfirm: async () => {
setLoading("reject");
try {
const res = await fetch(`/api/drafts/${draft.id}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify({ type: "error", title: "拒絕失敗", message: data.error });
return;
}
onUpdate();
} catch {
notify({ type: "error", title: "拒絕失敗", message: "網路連線異常,請稍後再試" });
} finally {
setLoading(null);
}
},
});
}
return (
<article
className="animate-fade-in-up py-7 first:pt-0"
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex gap-3">
{selectable && (
<label className="mt-2 flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-border accent-primary"
checked={selected}
onChange={(event) => onSelectedChange?.(event.target.checked)}
aria-label={`選取 ${draft.angle ?? "草稿"}`}
/>
</label>
)}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-muted-foreground">
{angleInitial}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span className="text-[15px] font-semibold">{draft.angle ?? "草稿"}</span>
<span className="text-muted-foreground">·</span>
<span className="text-[13px] text-muted-foreground">
{new Date(draft.createdAt).toLocaleDateString("zh-TW", {
month: "short",
day: "numeric",
})}
</span>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
{draft.draftType === "viral-replica" && (
<Badge variant="outline"></Badge>
)}
{factCheck && (factCheck.passed || !factCheck.isKnowledgeContent) && (
<span
className="relative inline-flex items-center gap-1 rounded-md border border-success-border bg-success-bg px-2 py-0.5 text-[11px] font-medium text-success"
title={
factCheck.isKnowledgeContent
? `已查證通過${factCheck.searchProviderLabel ? `${factCheck.searchProviderLabel}` : ""}\n${factCheck.summary}${factCheck.verifiedPoints.length > 0 ? `\n\n查證重點\n${factCheck.verifiedPoints.map((p) => `${p}`).join("\n")}` : ""}`
: "非知識型內容,無需查證"
}
>
<ShieldCheck className="h-3 w-3" />
</span>
)}
{factCheck && factCheck.isKnowledgeContent && !factCheck.passed && (
<span
className="relative inline-flex items-center gap-1 rounded-md border border-warning-border bg-warning-bg px-2 py-0.5 text-[11px] font-medium text-warning"
title={`查證未通過\n${factCheck.issues.join("")}`}
>
<ShieldCheck className="h-3 w-3" />
</span>
)}
</div>
{draft.rationale && (
<p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">
{draft.rationale}
</p>
)}
</div>
</div>
{showOptimize && (
<div className="mt-3 space-y-3 rounded-lg border border-border bg-muted p-3.5">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4" />
<p className="text-sm font-semibold">AI </p>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={optimizeMode} onValueChange={(v) => setOptimizeMode(v as OptimizeMode)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPTIMIZE_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{optimizeMode === "custom" && (
<div className="space-y-2">
<Label></Label>
<Input
value={customInstruction}
onChange={(e) => setCustomInstruction(e.target.value)}
placeholder="例:語氣更口語、加入一個反問句"
/>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => handleOptimize(false)} disabled={optimizing}>
<Wand2 className="h-3.5 w-3.5" />
{optimizing ? "優化中…" : "預覽優化"}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleOptimize(true)}
disabled={optimizing}
>
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowOptimize(false)}>
</Button>
</div>
</div>
)}
{optimizeSummary && (
<div className="mt-3 rounded-lg border border-border bg-muted px-3.5 py-2.5">
<p className="text-[12px] font-medium text-muted-foreground">AI 調</p>
<p className="mt-1 text-[14px] leading-relaxed">{optimizeSummary}</p>
</div>
)}
{factCheck && (
<div
className={cn(
"mt-3 rounded-lg border px-3.5 py-2.5 text-[13px]",
factCheck.passed || !factCheck.isKnowledgeContent
? "border-border bg-muted"
: "border-warning-border bg-warning-bg text-warning"
)}
>
<p className="font-medium">
{factCheck.isKnowledgeContent
? factCheck.passed
? "知識查證通過"
: "知識查證未通過"
: "非知識型,可直接發布"}
{factCheck.searchProviderLabel && ` · ${factCheck.searchProviderLabel}`}
</p>
<p className="mt-1 leading-relaxed text-muted-foreground">{factCheck.summary}</p>
{factCheck.issues.length > 0 && (
<ul className="mt-2 list-inside list-disc text-warning">
{factCheck.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{factCheck.sources.length > 0 && (
<div className="mt-2 space-y-1">
{factCheck.sources.map((s) => (
<a
key={s.link}
href={s.link}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-[12px] underline"
>
{s.title}
</a>
))}
</div>
)}
</div>
)}
{draft.hook && !editing && (
<p className="mt-2 text-[14px] font-medium leading-snug">{draft.hook}</p>
)}
<div className="mt-2">
{editing ? (
<div className="space-y-2">
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={6} />
<p
className={cn(
"font-mono text-xs",
overLimit ? "text-destructive" : "text-muted-foreground"
)}
>
{charCount} / {THREADS_MAX_CHARS}
</p>
</div>
) : (
<p className="font-readable whitespace-pre-wrap">{text}</p>
)}
</div>
<div className="mt-3 rounded-lg border border-border bg-muted/50 p-3.5">
<div className="mb-2.5 flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[13px] font-semibold">
<ImageIcon className="h-3.5 w-3.5" />
</p>
<div className="flex flex-wrap gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) void handleUploadImages(files);
e.target.value = "";
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={!!loading || optimizing}
>
{loading === "upload-image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
</Button>
{imagePaths.length > 0 && (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={handleClearImages}
disabled={!!loading || optimizing}
>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleGenerateImage}
disabled={!!loading || optimizing}
>
{loading === "gen-image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI
</Button>
</div>
</div>
{imagePaths.length > 0 ? (
<div
className={cn(
"grid gap-2.5",
imagePaths.length > 1 ? "sm:grid-cols-2" : "grid-cols-1"
)}
>
{imagePaths.map((imagePath, index) => (
<div
key={imagePath}
className="overflow-hidden rounded-lg border border-border bg-background"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreviewUrl(imagePath)}
alt={`草稿配圖 ${index + 1}`}
className="max-h-80 w-full object-contain"
/>
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<span className="text-[12px] text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-7 text-destructive hover:text-destructive"
onClick={() => handleRemoveImage(imagePath)}
disabled={!!loading || optimizing}
>
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{MAX_DRAFT_IMAGES} AI
</p>
)}
{draft.imageBrief && (
<div className="mt-2.5 rounded-md bg-background px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground"></p>
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-relaxed">
{draft.imageBrief}
</p>
</div>
)}
</div>
{sources.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{sources.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
</a>
))}
</div>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
{editing ? (
<>
<Button size="sm" onClick={handleSave} disabled={!!loading || overLimit || optimizing}>
</Button>
{previousText && (
<Button size="sm" variant="outline" onClick={handleUndoOptimize} disabled={optimizing}>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(false);
setText(draft.text);
setPreviousText(null);
setOptimizeSummary(null);
}}
>
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowOptimize((v) => !v)}
disabled={!!loading || optimizing}
>
<Wand2 className="h-3.5 w-3.5" />
AI
</Button>
<Button
size="sm"
variant="outline"
onClick={handleFactCheck}
disabled={!!loading || overLimit || optimizing}
>
<ShieldCheck className="h-3.5 w-3.5" />
{loading === "factcheck" ? "查證中…" : "知識查證"}
</Button>
<Button size="sm" onClick={handlePublish} disabled={!!loading || overLimit || optimizing || !canPublish} title={!canPublish ? "需先在連線設定綁定 Threads 或同步瀏覽器 Session" : undefined}>
<Send className="h-3.5 w-3.5" />
{loading === "publish" ? "發布中…" : "發布"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={handleReject}
disabled={!!loading || optimizing}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
<ConfirmDialog
open={confirmDialog !== null}
onOpenChange={(open) => !open && setConfirmDialog(null)}
title={confirmDialog?.title ?? ""}
description={confirmDialog?.description}
confirmText={confirmDialog?.confirmText}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm ?? (() => {})}
/>
</article>
);
}