855 lines
30 KiB
TypeScript
855 lines
30 KiB
TypeScript
"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>
|
||
);
|
||
}
|