返回 CodeWhale
admin-client.tsx
根目录 / web / app / [locale] / admin / admin-client.tsx
1 "use client";
2
3 import { useState } from "react";
4 import { draftStorageKey, 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 (draftKey: string, action: "post" | "discard", editedBody?: string) => {
21 setLoading(draftKey);
22 try {
23 const res = await fetch("/api/admin/post", {
24 method: "POST",
25 headers: { "Content-Type": "application/json" },
26 body: JSON.stringify({ action, draftKey, editedBody, lang: isZh ? "zh" : "en" }),
27 });
28 const data = await res.json();
29 if (data.ok) {
30 if (action === "discard") {
31 setItems((prev) => prev.filter((d) => draftStorageKey(d) !== draftKey));
32 } else if (action === "post") {
33 const posted = items.find((d) => draftStorageKey(d) === draftKey);
34 if (posted) {
35 setItems((prev) => prev.filter((d) => draftStorageKey(d) !== draftKey));
36 setPostedItems((prev) => [{ ...posted, posted: true }, ...prev]);
37 }
38 }
39 setEditing(null);
40 } else {
41 alert(`Error: ${data.error}`);
42 }
43 } catch (e) {
44 alert(`Network error: ${e}`);
45 } finally {
46 setLoading(null);
47 }
48 };
49
50 const startEdit = (draft: AgentDraft) => {
51 const key = draftStorageKey(draft);
52 setEditing(key);
53 setEditBody(isZh ? draft.bodyZh : draft.bodyEn);
54 };
55
56 return (
57 <>
58 {/* Pending drafts */}
59 {items.length > 0 && (
60 <div className="space-y-6">
61 <h2 className="font-display text-xl mt-8 mb-4">
62 {isZh ? "待审阅" : "Pending"} <span className="font-mono text-sm text-ink-mute ml-2">({items.length})</span>
63 </h2>
64 {items.map((draft) => {
65 const key = draftStorageKey(draft);
66 const label = typeLabels[draft.type] ?? { en: draft.type, zh: draft.type };
67 return (
68 <div key={key} className="hairline-t hairline-b hairline-l hairline-r bg-paper">
69 <div className="bg-ink text-paper px-4 py-2 flex items-center justify-between">
70 <div className="flex items-center gap-3">
71 <span className="font-mono text-xs uppercase tracking-wider text-indigo">
72 {isZh ? label.zh : label.en}
73 </span>
74 {draft.targetNumber && (
75 <span className="font-mono text-xs text-paper-deep/70 tabular">#{draft.targetNumber}</span>
76 )}
77 </div>
78 <span className="font-mono text-xs text-paper-deep/50">
79 {new Date(draft.generatedAt).toISOString().slice(0, 16)}
80 </span>
81 </div>
82
83 <div className="p-4">
84 {editing === key ? (
85 <div className="space-y-3">
86 <textarea
87 value={editBody}
88 onChange={(e) => setEditBody(e.target.value)}
89 className="w-full h-48 p-3 bg-paper-deep hairline-t hairline-b hairline-l hairline-r font-mono text-sm resize-y"
90 />
91 <div className="flex gap-2">
92 <button
93 onClick={() => handleAction(key, "post", editBody)}
94 disabled={loading === key}
95 className="px-4 py-2 bg-indigo text-paper font-mono text-xs uppercase tracking-wider hover:bg-indigo-deep transition-colors disabled:opacity-50"
96 >
97 {isZh ? "确认发布" : "Post edited"}
98 </button>
99 <button
100 onClick={() => setEditing(null)}
101 className="px-4 py-2 hairline-t hairline-b hairline-l hairline-r font-mono text-xs uppercase tracking-wider hover:bg-paper-deep transition-colors"
102 >
103 {isZh ? "取消" : "Cancel"}
104 </button>
105 </div>
106 </div>
107 ) : (
108 <>
109 <div className="prose prose-sm max-w-none text-sm text-ink-soft leading-relaxed whitespace-pre-wrap mb-4">
110 {isZh ? draft.bodyZh : draft.bodyEn}
111 </div>
112 <div className="flex gap-2 flex-wrap">
113 <button
114 onClick={() => handleAction(key, "post")}
115 disabled={loading === key}
116 className="px-4 py-2 bg-ink text-paper font-mono text-xs uppercase tracking-wider hover:bg-indigo transition-colors disabled:opacity-50"
117 >
118 {isZh ? "发布评论" : "Post as comment"}
119 </button>
120 <button
121 onClick={() => startEdit(draft)}
122 className="px-4 py-2 hairline-t hairline-b hairline-l hairline-r font-mono text-xs uppercase tracking-wider hover:bg-paper-deep transition-colors"
123 >
124 {isZh ? "编辑后发布" : "Edit & post"}
125 </button>
126 <button
127 onClick={() => handleAction(key, "discard")}
128 disabled={loading === key}
129 className="px-4 py-2 font-mono text-xs uppercase tracking-wider text-ink-mute hover:text-indigo transition-colors disabled:opacity-50"
130 >
131 {isZh ? "丢弃" : "Discard"}
132 </button>
133 </div>
134 </>
135 )}
136 </div>
137 </div>
138 );
139 })}
140 </div>
141 )}
142
143 {/* Posted drafts */}
144 {postedItems.length > 0 && (
145 <div className="mt-12">
146 <h2 className="font-display text-xl mb-4">
147 {isZh ? "已发布" : "Posted"} <span className="font-mono text-sm text-ink-mute ml-2">({postedItems.length})</span>
148 </h2>
149 {postedItems.map((draft) => {
150 const key = draftStorageKey(draft);
151 const label = typeLabels[draft.type] ?? { en: draft.type, zh: draft.type };
152 return (
153 <div key={key} className="hairline-t py-3 px-4 opacity-60">
154 <div className="flex items-center gap-3 mb-1">
155 <span className="font-mono text-xs uppercase tracking-wider text-ink-mute">
156 {isZh ? label.zh : label.en}
157 </span>
158 {draft.targetNumber && (
159 <span className="font-mono text-xs text-ink-mute tabular">#{draft.targetNumber}</span>
160 )}
161 <span className="ml-auto pill pill-jade text-[0.6rem]">{isZh ? "已发布" : "posted"}</span>
162 </div>
163 <p className="text-xs text-ink-mute line-clamp-2">{(isZh ? draft.bodyZh : draft.bodyEn).slice(0, 120)}…</p>
164 </div>
165 );
166 })}
167 </div>
168 )}
169 </>
170 );
171 }
172
172 lines Plain Text