返回 CodeWhale
admin-client.tsx
根目录 / web / app / [locale] / admin / admin-client.tsx
1 "use client";
2
3 import { useState } from "react";
4 import { draftStorageKey, reviewedBodyHash, type AgentDraft } from "@/lib/community-agent";
5
6 interface Props {
7 drafts: AgentDraft[];
8 posted: AgentDraft[];
9 isZh: boolean;
10 typeLabels: Record<string, { en: string; zh: string }>;
11 }
12
13 export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) {
14 const [items, setItems] = useState(drafts);
15 const [postedItems, setPostedItems] = useState(posted);
16 const [editing, setEditing] = useState<string | null>(null);
17 const [editBody, setEditBody] = useState("");
18 const [loading, setLoading] = useState<string | null>(null);
19
20 const handleAction = async (draft: AgentDraft, action: "post" | "discard", editedBody?: string) => {
21 const draftKey = draftStorageKey(draft);
22 setLoading(draftKey);
23 try {
24 // Binds the action to the text shown here; the route refuses it if the
25 // stored draft was regenerated since this page loaded.
26 const reviewedSha256 = await reviewedBodyHash(isZh ? draft.bodyZh : draft.bodyEn);
27 const res = await fetch("/api/admin/post", {
28 method: "POST",
29 headers: { "Content-Type": "application/json" },
30 body: JSON.stringify({ action, draftKey, editedBody, lang: isZh ? "zh" : "en", reviewedSha256 }),
31 });
32 const data = await res.json();
33 if (data.ok) {
34 if (action === "discard") {
35 setItems((prev) => prev.filter((d) => draftStorageKey(d) !== draftKey));
36 } else if (action === "post") {
37 const posted = items.find((d) => draftStorageKey(d) === draftKey);
38 if (posted) {
39 setItems((prev) => prev.filter((d) => draftStorageKey(d) !== draftKey));
40 setPostedItems((prev) => [{ ...posted, posted: true }, ...prev]);
41 }
42 }
43 setEditing(null);
44 // The post went through but something after it did not (draft state
45 // not saved, or the digest was not published on /digest).
46 if (typeof data.warning === "string") alert(data.warning);
47 } else {
48 alert(`Error: ${data.error}`);
49 }
50 } catch (e) {
51 alert(`Network error: ${e}`);
52 } finally {
53 setLoading(null);
54 }
55 };
56
57 const startEdit = (draft: AgentDraft) => {
58 const key = draftStorageKey(draft);
59 setEditing(key);
60 setEditBody(isZh ? draft.bodyZh : draft.bodyEn);
61 };
62
63 return (
64 <>
65 {/* Pending drafts */}
66 {items.length > 0 && (
67 <div className="space-y-6">
68 <h2 className="font-display text-xl mt-8 mb-4">
69 {isZh ? "待审阅" : "Pending"} <span className="font-mono text-sm text-ink-mute ml-2">({items.length})</span>
70 </h2>
71 {items.map((draft) => {
72 const key = draftStorageKey(draft);
73 const label = typeLabels[draft.type] ?? { en: draft.type, zh: draft.type };
74 return (
75 <div key={key} className="group-card">
76 <div className="bg-paper-deep text-ink px-4 py-2 flex items-center justify-between">
77 <div className="flex items-center gap-3">
78 <span className="text-xs text-indigo">
79 {isZh ? label.zh : label.en}
80 </span>
81 {draft.targetNumber && (
82 <span className="font-mono text-xs text-ink-mute tabular">#{draft.targetNumber}</span>
83 )}
84 </div>
85 <span className="font-mono text-xs text-ink-mute">
86 {new Date(draft.generatedAt).toISOString().slice(0, 16)}
87 </span>
88 </div>
89
90 <div className="p-4">
91 {editing === key ? (
92 <div className="space-y-3">
93 <textarea
94 value={editBody}
95 onChange={(e) => setEditBody(e.target.value)}
96 className="field-input h-48 py-3 font-mono text-sm resize-y"
97 />
98 <div className="flex gap-2">
99 <button
100 onClick={() => handleAction(draft, "post", editBody)}
101 disabled={loading === key}
102 className="btn btn-primary disabled:opacity-50"
103 >
104 {isZh ? "确认发布" : "Post edited"}
105 </button>
106 <button
107 onClick={() => setEditing(null)}
108 className="btn btn-secondary"
109 >
110 {isZh ? "取消" : "Cancel"}
111 </button>
112 </div>
113 </div>
114 ) : (
115 <>
116 <div className="prose prose-sm max-w-none text-sm text-ink-soft leading-relaxed whitespace-pre-wrap mb-4">
117 {isZh ? draft.bodyZh : draft.bodyEn}
118 </div>
119 <div className="flex gap-2 flex-wrap">
120 <button
121 onClick={() => handleAction(draft, "post")}
122 disabled={loading === key}
123 className="btn btn-primary disabled:opacity-50"
124 >
125 {isZh ? "发布评论" : "Post as comment"}
126 </button>
127 <button
128 onClick={() => startEdit(draft)}
129 className="btn btn-secondary"
130 >
131 {isZh ? "编辑后发布" : "Edit & post"}
132 </button>
133 <button
134 onClick={() => handleAction(draft, "discard")}
135 disabled={loading === key}
136 className="btn btn-danger"
137 >
138 {isZh ? "丢弃" : "Discard"}
139 </button>
140 </div>
141 </>
142 )}
143 </div>
144 </div>
145 );
146 })}
147 </div>
148 )}
149
150 {/* Posted drafts */}
151 {postedItems.length > 0 && (
152 <div className="mt-12">
153 <h2 className="font-display text-xl mb-4">
154 {isZh ? "已发布" : "Posted"} <span className="font-mono text-sm text-ink-mute ml-2">({postedItems.length})</span>
155 </h2>
156 {postedItems.map((draft) => {
157 const key = draftStorageKey(draft);
158 const label = typeLabels[draft.type] ?? { en: draft.type, zh: draft.type };
159 return (
160 <div key={key} className="hairline-t py-3 px-4 opacity-60">
161 <div className="flex items-center gap-3 mb-1">
162 <span className="text-xs text-ink-mute">
163 {isZh ? label.zh : label.en}
164 </span>
165 {draft.targetNumber && (
166 <span className="font-mono text-xs text-ink-mute tabular">#{draft.targetNumber}</span>
167 )}
168 <span className="ml-auto status status-ready"><span className="status-dot" aria-hidden="true" />{isZh ? "已发布" : "posted"}</span>
169 </div>
170 <p className="text-xs text-ink-mute line-clamp-2">{(isZh ? draft.bodyZh : draft.bodyEn).slice(0, 120)}…</p>
171 </div>
172 );
173 })}
174 </div>
175 )}
176 </>
177 );
178 }
179
179 lines Plain Text