@jiggai/kitchen-plugin-marketing 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -224,8 +224,141 @@ function getTeamId(req) {
224
224
  function getUserId(req) {
225
225
  return req.headers["x-user-id"] || "system";
226
226
  }
227
- async function handleRequest(req, _ctx) {
227
+ function getPostizConfig(req) {
228
+ const apiKey = req.query.postizApiKey || req.headers["x-postiz-api-key"];
229
+ const baseUrl = req.query.postizBaseUrl || req.headers["x-postiz-base-url"] || "https://api.postiz.com/public/v1";
230
+ if (!apiKey) return null;
231
+ return { apiKey, baseUrl: baseUrl.replace(/\/+$/, "") };
232
+ }
233
+ async function postizFetch(config, path, options) {
234
+ return fetch(`${config.baseUrl}${path}`, {
235
+ ...options,
236
+ headers: {
237
+ "Authorization": config.apiKey,
238
+ "Content-Type": "application/json",
239
+ ...options?.headers || {}
240
+ }
241
+ });
242
+ }
243
+ async function detectProviders(req, teamId) {
244
+ const providers = [];
245
+ const postizCfg = getPostizConfig(req);
246
+ if (postizCfg) {
247
+ try {
248
+ const res = await postizFetch(postizCfg, "/integrations");
249
+ if (res.ok) {
250
+ const data = await res.json();
251
+ const integrations = Array.isArray(data) ? data : data.integrations || [];
252
+ for (const integ of integrations) {
253
+ providers.push({
254
+ id: `postiz:${integ.id}`,
255
+ type: "postiz",
256
+ platform: integ.providerIdentifier || integ.provider || "unknown",
257
+ displayName: integ.name || integ.providerIdentifier || "Postiz account",
258
+ username: integ.username || void 0,
259
+ avatar: integ.picture || integ.avatar || void 0,
260
+ isActive: !integ.disabled,
261
+ capabilities: ["post", "schedule"],
262
+ meta: { postizId: integ.id, provider: integ.providerIdentifier }
263
+ });
264
+ }
265
+ }
266
+ } catch {
267
+ }
268
+ }
269
+ try {
270
+ const fs = await import("fs");
271
+ const path = await import("path");
272
+ const os = await import("os");
273
+ const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
274
+ if (fs.existsSync(configPath)) {
275
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
276
+ const plugins = cfg?.plugins?.entries || {};
277
+ if (plugins.discord?.enabled) {
278
+ providers.push({
279
+ id: "gateway:discord",
280
+ type: "gateway",
281
+ platform: "discord",
282
+ displayName: "Discord (via OpenClaw)",
283
+ isActive: true,
284
+ capabilities: ["post"],
285
+ meta: { channel: "discord" }
286
+ });
287
+ }
288
+ if (plugins.telegram?.enabled) {
289
+ providers.push({
290
+ id: "gateway:telegram",
291
+ type: "gateway",
292
+ platform: "telegram",
293
+ displayName: "Telegram (via OpenClaw)",
294
+ isActive: true,
295
+ capabilities: ["post"],
296
+ meta: { channel: "telegram" }
297
+ });
298
+ }
299
+ }
300
+ } catch {
301
+ }
302
+ return providers;
303
+ }
304
+ async function postizPublish(config, body) {
305
+ const payload = {
306
+ content: body.content,
307
+ integrationIds: body.integrationIds
308
+ };
309
+ if (body.scheduledAt) {
310
+ payload.date = body.scheduledAt;
311
+ }
312
+ if (body.settings) {
313
+ payload.settings = body.settings;
314
+ }
315
+ if (body.mediaUrls && body.mediaUrls.length > 0) {
316
+ payload.media = body.mediaUrls.map((url) => ({ url }));
317
+ }
318
+ const res = await postizFetch(config, "/posts", {
319
+ method: "POST",
320
+ body: JSON.stringify(payload)
321
+ });
322
+ const data = await res.json().catch(() => null);
323
+ if (!res.ok) {
324
+ return apiError(res.status, "POSTIZ_ERROR", data?.message || `Postiz returned ${res.status}`, data);
325
+ }
326
+ return { status: 201, data };
327
+ }
328
+ async function handleRequest(req, ctx) {
228
329
  const teamId = getTeamId(req);
330
+ if (req.path === "/providers" && req.method === "GET") {
331
+ try {
332
+ const providers = await detectProviders(req, teamId);
333
+ return { status: 200, data: { providers } };
334
+ } catch (error) {
335
+ return apiError(500, "DETECT_ERROR", error?.message || "Failed to detect providers");
336
+ }
337
+ }
338
+ if (req.path === "/providers/postiz/integrations" && req.method === "GET") {
339
+ const postizCfg = getPostizConfig(req);
340
+ if (!postizCfg) return apiError(400, "NO_POSTIZ", "Postiz API key not configured");
341
+ try {
342
+ const res = await postizFetch(postizCfg, "/integrations");
343
+ const data = await res.json();
344
+ return { status: res.status, data };
345
+ } catch (error) {
346
+ return apiError(502, "POSTIZ_UNREACHABLE", error?.message || "Cannot reach Postiz");
347
+ }
348
+ }
349
+ if (req.path === "/publish" && req.method === "POST") {
350
+ const postizCfg = getPostizConfig(req);
351
+ if (!postizCfg) return apiError(400, "NO_POSTIZ", "Postiz API key not configured");
352
+ const body = req.body || {};
353
+ if (!body.content || !body.integrationIds?.length) {
354
+ return apiError(400, "VALIDATION_ERROR", "content and integrationIds are required");
355
+ }
356
+ try {
357
+ return await postizPublish(postizCfg, body);
358
+ } catch (error) {
359
+ return apiError(502, "POSTIZ_ERROR", error?.message || "Publish failed");
360
+ }
361
+ }
229
362
  if (req.path === "/posts" && req.method === "GET") {
230
363
  try {
231
364
  const { db } = initializeDatabase(teamId);
@@ -29,85 +29,197 @@
29
29
  background: "var(--ck-accent-red)",
30
30
  border: "1px solid rgba(255,255,255,0.08)",
31
31
  borderRadius: "10px",
32
- padding: "0.6rem 0.85rem",
32
+ padding: "0.5rem 0.75rem",
33
33
  color: "white",
34
34
  fontWeight: 700,
35
- cursor: "pointer"
35
+ cursor: "pointer",
36
+ fontSize: "0.8rem"
36
37
  },
37
38
  btnGhost: {
38
39
  background: "rgba(255,255,255,0.03)",
39
40
  border: "1px solid var(--ck-border-subtle)",
40
41
  borderRadius: "10px",
41
- padding: "0.6rem 0.85rem",
42
+ padding: "0.5rem 0.75rem",
42
43
  color: "var(--ck-text-primary)",
43
44
  fontWeight: 600,
44
- cursor: "pointer"
45
- }
45
+ cursor: "pointer",
46
+ fontSize: "0.8rem"
47
+ },
48
+ badge: (color) => ({
49
+ display: "inline-block",
50
+ background: color,
51
+ borderRadius: "999px",
52
+ padding: "0.15rem 0.5rem",
53
+ fontSize: "0.7rem",
54
+ fontWeight: 600,
55
+ color: "white"
56
+ })
57
+ };
58
+ const PLATFORM_ICONS = {
59
+ x: "\u{1D54F}",
60
+ twitter: "\u{1D54F}",
61
+ instagram: "\u{1F4F7}",
62
+ linkedin: "\u{1F4BC}",
63
+ facebook: "\u{1F4D8}",
64
+ youtube: "\u25B6\uFE0F",
65
+ tiktok: "\u{1F3B5}",
66
+ bluesky: "\u{1F98B}",
67
+ mastodon: "\u{1F418}",
68
+ reddit: "\u{1F916}",
69
+ discord: "\u{1F4AC}",
70
+ telegram: "\u2708\uFE0F",
71
+ pinterest: "\u{1F4CC}",
72
+ threads: "\u{1F9F5}",
73
+ medium: "\u270D\uFE0F",
74
+ wordpress: "\u{1F4DD}"
75
+ };
76
+ const TYPE_COLORS = {
77
+ postiz: "rgba(99,179,237,0.7)",
78
+ gateway: "rgba(134,239,172,0.7)",
79
+ skill: "rgba(251,191,36,0.7)",
80
+ manual: "rgba(167,139,250,0.7)"
46
81
  };
47
82
  function Accounts(props) {
48
83
  const teamId = String(props?.teamId || "default");
49
84
  const apiBase = useMemo(() => `/api/plugins/marketing`, []);
50
- const [accounts, setAccounts] = useState([]);
85
+ const [providers, setProviders] = useState([]);
86
+ const [manualAccounts, setManualAccounts] = useState([]);
51
87
  const [loading, setLoading] = useState(true);
52
- const [open, setOpen] = useState(false);
53
- const [platform, setPlatform] = useState("twitter");
54
- const [displayName, setDisplayName] = useState("");
55
- const [username, setUsername] = useState("");
56
- const [accessToken, setAccessToken] = useState("");
57
- const [saving, setSaving] = useState(false);
88
+ const [detecting, setDetecting] = useState(false);
58
89
  const [error, setError] = useState(null);
59
- const refresh = async () => {
60
- setLoading(true);
90
+ const [postizKey, setPostizKey] = useState("");
91
+ const [postizUrl, setPostizUrl] = useState("https://api.postiz.com/public/v1");
92
+ const [showPostizSetup, setShowPostizSetup] = useState(false);
93
+ const [showManual, setShowManual] = useState(false);
94
+ const [manPlatform, setManPlatform] = useState("twitter");
95
+ const [manName, setManName] = useState("");
96
+ const [manUser, setManUser] = useState("");
97
+ const [manToken, setManToken] = useState("");
98
+ const [saving, setSaving] = useState(false);
99
+ useEffect(() => {
100
+ try {
101
+ const stored = localStorage.getItem(`ck-postiz-${teamId}`);
102
+ if (stored) {
103
+ const parsed = JSON.parse(stored);
104
+ setPostizKey(parsed.apiKey || "");
105
+ setPostizUrl(parsed.baseUrl || "https://api.postiz.com/public/v1");
106
+ }
107
+ } catch {
108
+ }
109
+ }, [teamId]);
110
+ const savePostizConfig = () => {
111
+ try {
112
+ localStorage.setItem(`ck-postiz-${teamId}`, JSON.stringify({ apiKey: postizKey, baseUrl: postizUrl }));
113
+ } catch {
114
+ }
115
+ setShowPostizSetup(false);
116
+ void detectAll();
117
+ };
118
+ const getStoredPostiz = () => {
119
+ try {
120
+ const stored = localStorage.getItem(`ck-postiz-${teamId}`);
121
+ if (stored) return JSON.parse(stored);
122
+ } catch {
123
+ }
124
+ return null;
125
+ };
126
+ const detectAll = async () => {
127
+ setDetecting(true);
61
128
  setError(null);
62
129
  try {
63
- const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
130
+ const stored = getStoredPostiz();
131
+ const key = postizKey || stored?.apiKey || "";
132
+ const url = postizUrl || stored?.baseUrl || "https://api.postiz.com/public/v1";
133
+ const headers = {};
134
+ if (key) {
135
+ headers["x-postiz-api-key"] = key;
136
+ headers["x-postiz-base-url"] = url;
137
+ }
138
+ const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers });
64
139
  const json = await res.json();
65
- setAccounts(Array.isArray(json.accounts) ? json.accounts : []);
140
+ setProviders(Array.isArray(json.providers) ? json.providers : []);
66
141
  } catch (e) {
67
- setError(e?.message || "Failed to load accounts");
142
+ setError(e?.message || "Failed to detect providers");
68
143
  } finally {
69
- setLoading(false);
144
+ setDetecting(false);
145
+ }
146
+ };
147
+ const loadManual = async () => {
148
+ try {
149
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
150
+ const json = await res.json();
151
+ setManualAccounts(Array.isArray(json.accounts) ? json.accounts : []);
152
+ } catch {
70
153
  }
71
154
  };
155
+ const refresh = async () => {
156
+ setLoading(true);
157
+ await Promise.all([detectAll(), loadManual()]);
158
+ setLoading(false);
159
+ };
72
160
  useEffect(() => {
73
161
  void refresh();
74
162
  }, [teamId]);
75
- const onConnect = async () => {
163
+ const onManualConnect = async () => {
76
164
  setSaving(true);
77
165
  setError(null);
78
166
  try {
79
- const res = await fetch(
80
- `${apiBase}/accounts?team=${encodeURIComponent(teamId)}`,
81
- {
82
- method: "POST",
83
- headers: { "content-type": "application/json" },
84
- body: JSON.stringify({
85
- platform,
86
- displayName: displayName || `${platform} account`,
87
- username: username || void 0,
88
- credentials: { accessToken },
89
- settings: {}
90
- })
91
- }
92
- );
93
- if (!res.ok) {
94
- const err = await res.json().catch(() => null);
95
- throw new Error(err?.message || `Connect failed (${res.status})`);
96
- }
97
- setOpen(false);
98
- setDisplayName("");
99
- setUsername("");
100
- setAccessToken("");
101
- await refresh();
167
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`, {
168
+ method: "POST",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify({
171
+ platform: manPlatform,
172
+ displayName: manName || `${manPlatform} account`,
173
+ username: manUser || void 0,
174
+ credentials: { accessToken: manToken }
175
+ })
176
+ });
177
+ if (!res.ok) throw new Error(`Failed (${res.status})`);
178
+ setShowManual(false);
179
+ setManName("");
180
+ setManUser("");
181
+ setManToken("");
182
+ await loadManual();
102
183
  } catch (e) {
103
- setError(e?.message || "Failed to connect account");
184
+ setError(e?.message || "Failed to connect");
104
185
  } finally {
105
186
  setSaving(false);
106
187
  }
107
188
  };
189
+ const allProviders = useMemo(() => {
190
+ const combined = [...providers];
191
+ for (const ma of manualAccounts) {
192
+ combined.push({
193
+ id: `manual:${ma.id}`,
194
+ type: "manual",
195
+ platform: ma.platform,
196
+ displayName: ma.displayName,
197
+ username: ma.username,
198
+ isActive: ma.isActive,
199
+ capabilities: ["post"]
200
+ });
201
+ }
202
+ return combined;
203
+ }, [providers, manualAccounts]);
204
+ const grouped = useMemo(() => {
205
+ const g = {};
206
+ for (const p of allProviders) {
207
+ const key = p.type;
208
+ if (!g[key]) g[key] = [];
209
+ g[key].push(p);
210
+ }
211
+ return g;
212
+ }, [allProviders]);
213
+ const typeLabels = {
214
+ postiz: "Postiz",
215
+ gateway: "OpenClaw Channels",
216
+ skill: "Skills",
217
+ manual: "Manual"
218
+ };
108
219
  return h(
109
220
  "div",
110
221
  { className: "space-y-3" },
222
+ // ---- Header ----
111
223
  h(
112
224
  "div",
113
225
  { style: t.card },
@@ -117,22 +229,81 @@
117
229
  h(
118
230
  "div",
119
231
  null,
120
- h("div", { className: "text-sm font-medium", style: t.text }, "Accounts"),
121
- h("div", { className: "mt-1 text-xs", style: t.faint }, "OAuth flows next. For now this stores an access token placeholder per team.")
232
+ h("div", { className: "text-sm font-medium", style: t.text }, "Connected Accounts"),
233
+ h(
234
+ "div",
235
+ { className: "mt-1 text-xs", style: t.faint },
236
+ `${allProviders.length} provider${allProviders.length !== 1 ? "s" : ""} detected`
237
+ )
238
+ ),
239
+ h(
240
+ "div",
241
+ { className: "flex flex-wrap gap-2" },
242
+ h(
243
+ "button",
244
+ { type: "button", onClick: () => void refresh(), style: t.btnGhost, disabled: detecting },
245
+ detecting ? "Detecting\u2026" : "\u21BB Refresh"
246
+ ),
247
+ h(
248
+ "button",
249
+ { type: "button", onClick: () => setShowPostizSetup(!showPostizSetup), style: t.btnGhost },
250
+ postizKey ? "\u2699 Postiz" : "+ Postiz"
251
+ ),
252
+ h("button", { type: "button", onClick: () => setShowManual(!showManual), style: t.btnGhost }, "+ Manual")
253
+ )
254
+ ),
255
+ error && h("div", { className: "mt-2 text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error)
256
+ ),
257
+ // ---- Postiz setup ----
258
+ showPostizSetup && h(
259
+ "div",
260
+ { style: t.card },
261
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Postiz Configuration"),
262
+ h(
263
+ "div",
264
+ { className: "text-xs mb-3", style: t.faint },
265
+ "Connect Postiz to manage social accounts via their platform. Get your API key from Postiz Settings \u2192 Developers \u2192 Public API."
266
+ ),
267
+ h(
268
+ "div",
269
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
270
+ h(
271
+ "div",
272
+ null,
273
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "API Key"),
274
+ h("input", {
275
+ type: "password",
276
+ value: postizKey,
277
+ onChange: (e) => setPostizKey(e.target.value),
278
+ placeholder: "your-postiz-api-key",
279
+ style: t.input
280
+ })
122
281
  ),
123
282
  h(
124
283
  "div",
125
- { className: "flex gap-2" },
126
- h("button", { type: "button", onClick: () => void refresh(), style: t.btnGhost }, "Refresh"),
127
- h("button", { type: "button", onClick: () => setOpen(true), style: t.btnPrimary }, "Connect")
284
+ null,
285
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Base URL"),
286
+ h("input", {
287
+ value: postizUrl,
288
+ onChange: (e) => setPostizUrl(e.target.value),
289
+ placeholder: "https://api.postiz.com/public/v1",
290
+ style: t.input
291
+ })
128
292
  )
129
293
  ),
130
- error ? h("div", { className: "mt-2 text-sm", style: { color: "rgba(248,113,113,0.95)" } }, error) : null
294
+ h(
295
+ "div",
296
+ { className: "mt-3 flex gap-2" },
297
+ h("button", { type: "button", onClick: () => setShowPostizSetup(false), style: t.btnGhost }, "Cancel"),
298
+ h("button", { type: "button", onClick: savePostizConfig, style: t.btnPrimary }, postizKey ? "Save & Detect" : "Save")
299
+ )
131
300
  ),
132
- open ? h(
301
+ // ---- Manual account form ----
302
+ showManual && h(
133
303
  "div",
134
304
  { style: t.card },
135
- h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Connect account"),
305
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Add manual account"),
306
+ h("div", { className: "text-xs mb-3", style: t.faint }, "For direct API access without Postiz. You provide the token."),
136
307
  h(
137
308
  "div",
138
309
  { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
@@ -142,78 +313,117 @@
142
313
  h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Platform"),
143
314
  h(
144
315
  "select",
145
- {
146
- value: platform,
147
- onChange: (e) => setPlatform(e.target.value),
148
- style: t.input
149
- },
316
+ { value: manPlatform, onChange: (e) => setManPlatform(e.target.value), style: t.input },
150
317
  h("option", { value: "twitter" }, "Twitter / X"),
151
318
  h("option", { value: "instagram" }, "Instagram"),
152
- h("option", { value: "linkedin" }, "LinkedIn")
319
+ h("option", { value: "linkedin" }, "LinkedIn"),
320
+ h("option", { value: "bluesky" }, "Bluesky"),
321
+ h("option", { value: "mastodon" }, "Mastodon")
153
322
  )
154
323
  ),
155
324
  h(
156
325
  "div",
157
326
  null,
158
327
  h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Display name"),
159
- h("input", {
160
- value: displayName,
161
- onChange: (e) => setDisplayName(e.target.value),
162
- placeholder: "e.g. RJ \u2014 Main",
163
- style: t.input
164
- })
328
+ h("input", { value: manName, onChange: (e) => setManName(e.target.value), placeholder: "My X account", style: t.input })
165
329
  ),
166
330
  h(
167
331
  "div",
168
332
  null,
169
- h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Username (optional)"),
170
- h("input", {
171
- value: username,
172
- onChange: (e) => setUsername(e.target.value),
173
- placeholder: "e.g. @handle",
174
- style: t.input
175
- })
333
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Username"),
334
+ h("input", { value: manUser, onChange: (e) => setManUser(e.target.value), placeholder: "@handle", style: t.input })
176
335
  ),
177
336
  h(
178
337
  "div",
179
338
  null,
180
- h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Access token (placeholder)"),
181
- h("input", {
182
- value: accessToken,
183
- onChange: (e) => setAccessToken(e.target.value),
184
- placeholder: "token\u2026",
185
- style: t.input
186
- })
339
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Access token"),
340
+ h("input", { type: "password", value: manToken, onChange: (e) => setManToken(e.target.value), placeholder: "token\u2026", style: t.input })
187
341
  )
188
342
  ),
189
343
  h(
190
344
  "div",
191
345
  { className: "mt-3 flex gap-2" },
192
- h("button", { type: "button", onClick: () => setOpen(false), style: t.btnGhost }, "Cancel"),
193
- h("button", { type: "button", onClick: () => void onConnect(), style: { ...t.btnPrimary, opacity: saving ? 0.7 : 1 }, disabled: saving }, saving ? "Connecting\u2026" : "Save")
346
+ h("button", { type: "button", onClick: () => setShowManual(false), style: t.btnGhost }, "Cancel"),
347
+ h("button", { type: "button", onClick: () => void onManualConnect(), style: t.btnPrimary, disabled: saving }, saving ? "Saving\u2026" : "Connect")
194
348
  )
195
- ) : null,
196
- h(
349
+ ),
350
+ // ---- Loading ----
351
+ loading && h(
197
352
  "div",
198
353
  { style: t.card },
199
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Connected"),
200
- loading ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Loading\u2026") : accounts.length === 0 ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No accounts connected yet.") : h(
354
+ h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Detecting providers\u2026")
355
+ ),
356
+ // ---- Provider groups ----
357
+ !loading && allProviders.length === 0 && h(
358
+ "div",
359
+ { style: t.card },
360
+ h(
201
361
  "div",
202
- { className: "space-y-2" },
203
- ...accounts.map(
204
- (a) => h(
205
- "div",
206
- { key: a.id, style: { ...t.card, padding: "0.75rem" } },
207
- h(
362
+ { className: "py-6 text-center space-y-2" },
363
+ h("div", { className: "text-sm", style: t.faint }, "No providers detected"),
364
+ h(
365
+ "div",
366
+ { className: "text-xs", style: t.faint },
367
+ "Connect Postiz for full social media management, or add accounts manually."
368
+ )
369
+ )
370
+ ),
371
+ !loading && Object.entries(grouped).map(
372
+ ([type, items]) => h(
373
+ "div",
374
+ { key: type, style: t.card },
375
+ h(
376
+ "div",
377
+ { className: "flex items-center gap-2 mb-3" },
378
+ h("div", { className: "text-sm font-medium", style: t.text }, typeLabels[type] || type),
379
+ h("span", { style: t.badge(TYPE_COLORS[type] || "rgba(100,100,100,0.6)") }, `${items.length}`)
380
+ ),
381
+ h(
382
+ "div",
383
+ { className: "space-y-2" },
384
+ ...items.map(
385
+ (p) => h(
208
386
  "div",
209
- { className: "flex items-center justify-between gap-2" },
387
+ { key: p.id, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem" } },
388
+ // Avatar or platform icon
389
+ p.avatar ? h("img", { src: p.avatar, alt: "", style: { width: 32, height: 32, borderRadius: "50%", objectFit: "cover" } }) : h("div", {
390
+ style: {
391
+ width: 32,
392
+ height: 32,
393
+ borderRadius: "50%",
394
+ background: "rgba(255,255,255,0.06)",
395
+ display: "flex",
396
+ alignItems: "center",
397
+ justifyContent: "center",
398
+ fontSize: "1rem"
399
+ }
400
+ }, PLATFORM_ICONS[p.platform] || "\u{1F517}"),
401
+ // Info
210
402
  h(
211
403
  "div",
212
- null,
213
- h("div", { className: "text-sm font-medium", style: t.text }, a.displayName),
214
- h("div", { className: "text-xs", style: t.faint }, `${a.platform}${a.username ? ` \xB7 ${a.username}` : ""}`)
404
+ { style: { flex: 1, minWidth: 0 } },
405
+ h("div", { className: "text-sm font-medium", style: t.text }, p.displayName),
406
+ h(
407
+ "div",
408
+ { className: "text-xs", style: t.faint },
409
+ [p.platform, p.username].filter(Boolean).join(" \xB7 ")
410
+ )
215
411
  ),
216
- h("div", { className: "text-xs", style: a.isActive ? t.muted : t.faint }, a.isActive ? "active" : "disabled")
412
+ // Status + capabilities
413
+ h(
414
+ "div",
415
+ { className: "flex items-center gap-2 shrink-0" },
416
+ p.capabilities?.includes("schedule") && h("span", { className: "text-xs", style: t.faint }, "\u23F1"),
417
+ p.capabilities?.includes("post") && h("span", { className: "text-xs", style: t.faint }, "\u{1F4E4}"),
418
+ h("div", {
419
+ style: {
420
+ width: 8,
421
+ height: 8,
422
+ borderRadius: "50%",
423
+ background: p.isActive ? "rgba(74,222,128,0.8)" : "rgba(248,113,113,0.6)"
424
+ }
425
+ })
426
+ )
217
427
  )
218
428
  )
219
429
  )
@@ -7,6 +7,7 @@
7
7
  const useEffect = R.useEffect;
8
8
  const useMemo = R.useMemo;
9
9
  const useState = R.useState;
10
+ const useCallback = R.useCallback;
10
11
  const t = {
11
12
  text: { color: "var(--ck-text-primary)" },
12
13
  muted: { color: "var(--ck-text-secondary)" },
@@ -43,6 +44,15 @@
43
44
  fontWeight: 600,
44
45
  cursor: "pointer"
45
46
  },
47
+ btnPublish: {
48
+ background: "rgba(99,179,237,0.2)",
49
+ border: "1px solid rgba(99,179,237,0.4)",
50
+ borderRadius: "10px",
51
+ padding: "0.6rem 0.85rem",
52
+ color: "rgba(210,235,255,0.95)",
53
+ fontWeight: 700,
54
+ cursor: "pointer"
55
+ },
46
56
  pill: (active) => ({
47
57
  background: active ? "rgba(99,179,237,0.16)" : "rgba(255,255,255,0.03)",
48
58
  border: `1px solid ${active ? "rgba(99,179,237,0.45)" : "var(--ck-border-subtle)"}`,
@@ -52,81 +62,162 @@
52
62
  color: active ? "rgba(210,235,255,0.95)" : "var(--ck-text-secondary)",
53
63
  cursor: "pointer",
54
64
  userSelect: "none"
55
- })
65
+ }),
66
+ statusBadge: (status) => {
67
+ const colors = {
68
+ draft: "rgba(167,139,250,0.7)",
69
+ scheduled: "rgba(251,191,36,0.7)",
70
+ published: "rgba(74,222,128,0.7)",
71
+ failed: "rgba(248,113,113,0.7)"
72
+ };
73
+ return {
74
+ display: "inline-block",
75
+ background: colors[status] || "rgba(100,100,100,0.5)",
76
+ borderRadius: "999px",
77
+ padding: "0.1rem 0.45rem",
78
+ fontSize: "0.7rem",
79
+ fontWeight: 600,
80
+ color: "white"
81
+ };
82
+ }
56
83
  };
57
84
  function ContentLibrary(props) {
58
85
  const teamId = String(props?.teamId || "default");
86
+ const apiBase = useMemo(() => `/api/plugins/marketing`, []);
59
87
  const [posts, setPosts] = useState([]);
88
+ const [providers, setProviders] = useState([]);
60
89
  const [loading, setLoading] = useState(true);
61
90
  const [saving, setSaving] = useState(false);
91
+ const [publishing, setPublishing] = useState(false);
62
92
  const [error, setError] = useState(null);
93
+ const [success, setSuccess] = useState(null);
63
94
  const [content, setContent] = useState("");
64
- const [platforms, setPlatforms] = useState(["twitter"]);
95
+ const [selectedProviders, setSelectedProviders] = useState([]);
65
96
  const [scheduledAt, setScheduledAt] = useState("");
66
- const status = useMemo(() => scheduledAt ? "scheduled" : "draft", [scheduledAt]);
67
- const apiBase = useMemo(() => `/api/plugins/marketing`, []);
68
- const refresh = async () => {
69
- setLoading(true);
70
- setError(null);
97
+ const postizHeaders = useMemo(() => {
98
+ try {
99
+ const stored = localStorage.getItem(`ck-postiz-${teamId}`);
100
+ if (stored) {
101
+ const parsed = JSON.parse(stored);
102
+ if (parsed.apiKey) {
103
+ return {
104
+ "x-postiz-api-key": parsed.apiKey,
105
+ "x-postiz-base-url": parsed.baseUrl || "https://api.postiz.com/public/v1"
106
+ };
107
+ }
108
+ }
109
+ } catch {
110
+ }
111
+ return {};
112
+ }, [teamId]);
113
+ const loadPosts = useCallback(async () => {
71
114
  try {
72
- const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=25&offset=0`);
115
+ const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=25`);
73
116
  const json = await res.json();
74
117
  setPosts(Array.isArray(json.data) ? json.data : []);
75
- } catch (e) {
76
- setError(e?.message || "Failed to load posts");
77
- } finally {
78
- setLoading(false);
118
+ } catch {
79
119
  }
80
- };
120
+ }, [apiBase, teamId]);
121
+ const loadProviders = useCallback(async () => {
122
+ try {
123
+ const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers: postizHeaders });
124
+ const json = await res.json();
125
+ const detected = Array.isArray(json.providers) ? json.providers : [];
126
+ setProviders(detected);
127
+ } catch {
128
+ }
129
+ }, [apiBase, teamId, postizHeaders]);
81
130
  useEffect(() => {
82
- void refresh();
83
- }, [teamId]);
84
- const togglePlatform = (p) => {
85
- setPlatforms((prev) => {
86
- if (prev.includes(p)) return prev.filter((x) => x !== p);
87
- return [...prev, p];
88
- });
131
+ setLoading(true);
132
+ Promise.all([loadPosts(), loadProviders()]).finally(() => setLoading(false));
133
+ }, [loadPosts, loadProviders]);
134
+ const toggleProvider = (id) => {
135
+ setSelectedProviders(
136
+ (prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
137
+ );
89
138
  };
90
- const onCreate = async () => {
139
+ const onSaveDraft = async () => {
140
+ if (!content.trim()) return;
91
141
  setSaving(true);
92
142
  setError(null);
93
143
  try {
94
- const res = await fetch(
95
- `${apiBase}/posts?team=${encodeURIComponent(teamId)}`,
96
- {
144
+ const platforms = selectedProviders.map((id) => providers.find((p) => p.id === id)?.platform).filter(Boolean);
145
+ const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}`, {
146
+ method: "POST",
147
+ headers: { "content-type": "application/json" },
148
+ body: JSON.stringify({
149
+ content,
150
+ platforms: platforms.length > 0 ? platforms : ["draft"],
151
+ status: scheduledAt ? "scheduled" : "draft",
152
+ scheduledAt: scheduledAt || void 0
153
+ })
154
+ });
155
+ if (!res.ok) throw new Error(`Save failed (${res.status})`);
156
+ setContent("");
157
+ setScheduledAt("");
158
+ setSelectedProviders([]);
159
+ await loadPosts();
160
+ } catch (e) {
161
+ setError(e?.message || "Failed to save");
162
+ } finally {
163
+ setSaving(false);
164
+ }
165
+ };
166
+ const onPublish = async () => {
167
+ if (!content.trim() || selectedProviders.length === 0) return;
168
+ setPublishing(true);
169
+ setError(null);
170
+ setSuccess(null);
171
+ const postizProviders = selectedProviders.filter((id) => id.startsWith("postiz:"));
172
+ const gatewayProviders = selectedProviders.filter((id) => id.startsWith("gateway:"));
173
+ try {
174
+ if (postizProviders.length > 0) {
175
+ const integrationIds = postizProviders.map((id) => {
176
+ const prov = providers.find((p) => p.id === id);
177
+ return prov?.meta?.postizId;
178
+ }).filter(Boolean);
179
+ const res = await fetch(`${apiBase}/publish?team=${encodeURIComponent(teamId)}`, {
97
180
  method: "POST",
98
- headers: { "content-type": "application/json" },
181
+ headers: { "content-type": "application/json", ...postizHeaders },
99
182
  body: JSON.stringify({
100
183
  content,
101
- platforms,
102
- status,
184
+ integrationIds,
103
185
  scheduledAt: scheduledAt || void 0
104
186
  })
187
+ });
188
+ if (!res.ok) {
189
+ const err = await res.json().catch(() => null);
190
+ throw new Error(err?.message || `Postiz publish failed (${res.status})`);
105
191
  }
106
- );
107
- if (!res.ok) {
108
- const err = await res.json().catch(() => null);
109
- throw new Error(err?.message || `Create failed (${res.status})`);
192
+ }
193
+ if (gatewayProviders.length > 0 && postizProviders.length === 0) {
194
+ setSuccess("Saved! Gateway posting requires OpenClaw agent \u2014 use the workflow or ask your assistant to post.");
195
+ } else {
196
+ setSuccess(scheduledAt ? "Scheduled via Postiz!" : "Published via Postiz!");
110
197
  }
111
198
  setContent("");
112
199
  setScheduledAt("");
113
- await refresh();
200
+ setSelectedProviders([]);
201
+ await loadPosts();
114
202
  } catch (e) {
115
- setError(e?.message || "Failed to create post");
203
+ setError(e?.message || "Publish failed");
116
204
  } finally {
117
- setSaving(false);
205
+ setPublishing(false);
118
206
  }
119
207
  };
208
+ const postizAvailable = providers.some((p) => p.type === "postiz");
209
+ const hasSelection = selectedProviders.length > 0;
120
210
  return h(
121
211
  "div",
122
212
  { className: "space-y-3" },
213
+ // ---- Composer ----
123
214
  h(
124
215
  "div",
125
216
  { style: t.card },
126
- h("div", { className: "text-sm font-medium mb-3", style: t.text }, "New Post"),
217
+ h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Compose"),
127
218
  h(
128
219
  "div",
129
- { className: "space-y-2" },
220
+ { className: "space-y-3" },
130
221
  h("textarea", {
131
222
  value: content,
132
223
  onChange: (e) => setContent(e.target.value),
@@ -134,20 +225,36 @@
134
225
  rows: 5,
135
226
  style: { ...t.input, resize: "vertical", minHeight: "110px" }
136
227
  }),
137
- h(
228
+ // Provider selector
229
+ providers.length > 0 && h(
138
230
  "div",
139
- { className: "flex flex-wrap gap-2 items-center" },
140
- h("div", { className: "text-xs font-medium", style: t.faint }, "Platforms"),
141
- ["twitter", "instagram", "linkedin"].map(
142
- (p) => h("span", {
143
- key: p,
144
- onClick: () => togglePlatform(p),
145
- style: t.pill(platforms.includes(p)),
146
- role: "button",
147
- tabIndex: 0
148
- }, p)
231
+ null,
232
+ h("div", { className: "text-xs font-medium mb-2", style: t.faint }, "Publish to"),
233
+ h(
234
+ "div",
235
+ { className: "flex flex-wrap gap-2" },
236
+ ...providers.filter((p) => p.isActive).map(
237
+ (p) => h(
238
+ "span",
239
+ {
240
+ key: p.id,
241
+ onClick: () => toggleProvider(p.id),
242
+ style: t.pill(selectedProviders.includes(p.id)),
243
+ role: "button",
244
+ tabIndex: 0
245
+ },
246
+ `${p.displayName}`
247
+ )
248
+ )
149
249
  )
150
250
  ),
251
+ // No providers hint
252
+ providers.length === 0 && !loading && h(
253
+ "div",
254
+ { className: "text-xs", style: t.faint },
255
+ "No publishing targets detected. Go to Accounts tab to connect Postiz or add accounts."
256
+ ),
257
+ // Schedule
151
258
  h(
152
259
  "div",
153
260
  { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
@@ -164,38 +271,50 @@
164
271
  ),
165
272
  h(
166
273
  "div",
167
- null,
168
- h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Status"),
169
- h("div", { className: "text-sm", style: t.muted }, status)
274
+ { className: "flex items-end" },
275
+ h(
276
+ "div",
277
+ { className: "text-xs", style: t.faint },
278
+ content.length > 0 ? `${content.length} chars` : ""
279
+ )
170
280
  )
171
281
  ),
282
+ // Actions
172
283
  h(
173
284
  "div",
174
285
  { className: "flex flex-wrap gap-2 items-center" },
175
286
  h("button", {
176
287
  type: "button",
177
- onClick: () => void refresh(),
178
- style: t.btnGhost,
179
- disabled: saving
180
- }, loading ? "Refreshing\u2026" : "Refresh"),
181
- h("button", {
182
- type: "button",
183
- onClick: () => void onCreate(),
184
- style: { ...t.btnPrimary, opacity: saving ? 0.7 : 1 },
185
- disabled: saving
288
+ onClick: () => void onSaveDraft(),
289
+ style: { ...t.btnGhost, opacity: saving ? 0.7 : 1 },
290
+ disabled: saving || !content.trim()
186
291
  }, saving ? "Saving\u2026" : "Save draft"),
187
- h("div", { className: "text-xs", style: t.faint }, "Media embedding + templates next.")
292
+ postizAvailable && hasSelection && h("button", {
293
+ type: "button",
294
+ onClick: () => void onPublish(),
295
+ style: { ...t.btnPublish, opacity: publishing ? 0.7 : 1 },
296
+ disabled: publishing || !content.trim()
297
+ }, publishing ? "Publishing\u2026" : scheduledAt ? "\u23F1 Schedule" : "\u{1F4E4} Publish"),
298
+ !postizAvailable && hasSelection && h(
299
+ "div",
300
+ { className: "text-xs", style: t.faint },
301
+ "Connect Postiz on Accounts tab to publish directly."
302
+ )
188
303
  ),
189
- error ? h("div", {
190
- className: "text-sm mt-2",
191
- style: { color: "rgba(248,113,113,0.95)" }
192
- }, error) : null
304
+ error && h("div", { className: "text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error),
305
+ success && h("div", { className: "text-xs", style: { color: "rgba(74,222,128,0.9)" } }, success)
193
306
  )
194
307
  ),
308
+ // ---- Posts list ----
195
309
  h(
196
310
  "div",
197
311
  { style: t.card },
198
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Posts"),
312
+ h(
313
+ "div",
314
+ { className: "flex items-center justify-between mb-2" },
315
+ h("div", { className: "text-sm font-medium", style: t.text }, "Posts"),
316
+ h("button", { type: "button", onClick: () => void loadPosts(), style: t.btnGhost, className: "text-xs" }, "\u21BB")
317
+ ),
199
318
  loading ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Loading\u2026") : posts.length === 0 ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No posts yet.") : h(
200
319
  "div",
201
320
  { className: "space-y-2" },
@@ -206,14 +325,19 @@
206
325
  h(
207
326
  "div",
208
327
  { className: "flex items-center justify-between gap-2" },
209
- h("div", { className: "text-xs font-medium", style: t.faint }, new Date(p.createdAt).toLocaleString()),
210
- h("div", { className: "text-xs", style: t.muted }, `${p.status}${p.scheduledAt ? ` \xB7 ${p.scheduledAt}` : ""}`)
328
+ h(
329
+ "div",
330
+ { className: "flex items-center gap-2" },
331
+ h("span", { style: t.statusBadge(p.status) }, p.status),
332
+ h("span", { className: "text-xs", style: t.faint }, new Date(p.createdAt).toLocaleString())
333
+ ),
334
+ p.scheduledAt && h("div", { className: "text-xs", style: t.muted }, `\u23F1 ${new Date(p.scheduledAt).toLocaleString()}`)
211
335
  ),
212
336
  h("div", { className: "mt-2 whitespace-pre-wrap text-sm", style: t.text }, p.content),
213
- h(
337
+ p.platforms?.length > 0 && h(
214
338
  "div",
215
- { className: "mt-2 flex flex-wrap gap-2" },
216
- ...(p.platforms || []).map((pl) => h("span", { key: pl, style: t.pill(true) }, pl))
339
+ { className: "mt-2 flex flex-wrap gap-1" },
340
+ ...p.platforms.map((pl) => h("span", { key: pl, style: t.pill(true) }, pl))
217
341
  )
218
342
  )
219
343
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/kitchen-plugin-marketing",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [