"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 = { 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): 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(null); const [optimizing, setOptimizing] = useState(false); const [showOptimize, setShowOptimize] = useState(false); const [optimizeMode, setOptimizeMode] = useState("polish"); const [customInstruction, setCustomInstruction] = useState(""); const [optimizeSummary, setOptimizeSummary] = useState(null); const [previousText, setPreviousText] = useState(null); const fileInputRef = useRef(null); const { isReady } = useCapabilities(); const canPublish = isReady("publish"); const [imagePaths, setImagePaths] = useState(() => parseDraftImagePaths(draft)); const [confirmDialog, setConfirmDialog] = useState<{ title: string; description?: string; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise; } | null>(null); useEffect(() => { setImagePaths( parseDraftImagePaths({ imagePath: draft.imagePath, imagePaths: draft.imagePaths }) ); }, [draft.imagePath, draft.imagePaths]); const [factCheck, setFactCheck] = useState(() => 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 (
{selectable && ( )}
{angleInitial}
{draft.angle ?? "草稿"} · {new Date(draft.createdAt).toLocaleDateString("zh-TW", { month: "short", day: "numeric", })} {statusInfo.label} {draft.draftType === "viral-replica" && ( 爆款複製 )} {factCheck && (factCheck.passed || !factCheck.isKnowledgeContent) && ( 0 ? `\n\n查證重點:\n${factCheck.verifiedPoints.map((p) => `• ${p}`).join("\n")}` : ""}` : "非知識型內容,無需查證" } > 已查證 )} {factCheck && factCheck.isKnowledgeContent && !factCheck.passed && ( 查證未過 )}
{draft.rationale && (

{draft.rationale}

)}
{showOptimize && (

AI 優化

{optimizeMode === "custom" && (
setCustomInstruction(e.target.value)} placeholder="例:語氣更口語、加入一個反問句" />
)}
)} {optimizeSummary && (

AI 調整說明

{optimizeSummary}

)} {factCheck && (

{factCheck.isKnowledgeContent ? factCheck.passed ? "知識查證通過" : "知識查證未通過" : "非知識型,可直接發布"} {factCheck.searchProviderLabel && ` · ${factCheck.searchProviderLabel}`}

{factCheck.summary}

{factCheck.issues.length > 0 && (
    {factCheck.issues.map((issue) => (
  • {issue}
  • ))}
)} {factCheck.sources.length > 0 && (
{factCheck.sources.map((s) => ( {s.title} ))}
)}
)} {draft.hook && !editing && (

{draft.hook}

)}
{editing ? (