haixunMaster/components/draft-card.tsx

855 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}