@jiggai/kitchen-plugin-marketing 0.2.9 → 0.2.10

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.
@@ -53,59 +53,59 @@
53
53
  fontSize: "0.7rem",
54
54
  fontWeight: 600,
55
55
  color: "white"
56
+ }),
57
+ capPill: (active) => ({
58
+ display: "inline-block",
59
+ background: active ? "rgba(99,179,237,0.15)" : "rgba(255,255,255,0.03)",
60
+ border: `1px solid ${active ? "rgba(99,179,237,0.3)" : "var(--ck-border-subtle)"}`,
61
+ borderRadius: "999px",
62
+ padding: "0.1rem 0.4rem",
63
+ fontSize: "0.6rem",
64
+ color: active ? "rgba(210,235,255,0.9)" : "var(--ck-text-tertiary)"
56
65
  })
57
66
  };
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 = {
67
+ const BACKEND_COLORS = {
77
68
  postiz: "rgba(99,179,237,0.7)",
78
69
  gateway: "rgba(134,239,172,0.7)",
79
- skill: "rgba(251,191,36,0.7)",
80
- manual: "rgba(167,139,250,0.7)"
70
+ direct: "rgba(251,191,36,0.7)",
71
+ none: "rgba(100,100,100,0.5)"
72
+ };
73
+ const BACKEND_LABELS = {
74
+ postiz: "Postiz",
75
+ gateway: "OpenClaw",
76
+ direct: "Direct API",
77
+ none: "Not connected"
81
78
  };
82
79
  function Accounts(props) {
83
80
  const teamId = String(props?.teamId || "default");
84
81
  const apiBase = useMemo(() => `/api/plugins/marketing`, []);
85
- const [providers, setProviders] = useState([]);
82
+ const [drivers, setDrivers] = useState([]);
86
83
  const [manualAccounts, setManualAccounts] = useState([]);
87
84
  const [loading, setLoading] = useState(true);
88
- const [detecting, setDetecting] = useState(false);
89
85
  const [error, setError] = useState(null);
90
86
  const [postizKey, setPostizKey] = useState("");
91
87
  const [postizUrl, setPostizUrl] = useState("https://api.postiz.com/public/v1");
92
88
  const [showPostizSetup, setShowPostizSetup] = useState(false);
93
89
  const [showManual, setShowManual] = useState(false);
94
- const [manPlatform, setManPlatform] = useState("twitter");
90
+ const [manPlatform, setManPlatform] = useState("x");
95
91
  const [manName, setManName] = useState("");
96
92
  const [manUser, setManUser] = useState("");
97
93
  const [manToken, setManToken] = useState("");
98
94
  const [saving, setSaving] = useState(false);
99
- useEffect(() => {
95
+ const getStoredPostiz = () => {
100
96
  try {
101
97
  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
- }
98
+ if (stored) return JSON.parse(stored);
107
99
  } catch {
108
100
  }
101
+ return null;
102
+ };
103
+ useEffect(() => {
104
+ const stored = getStoredPostiz();
105
+ if (stored) {
106
+ setPostizKey(stored.apiKey || "");
107
+ setPostizUrl(stored.baseUrl || "https://api.postiz.com/public/v1");
108
+ }
109
109
  }, [teamId]);
110
110
  const savePostizConfig = () => {
111
111
  try {
@@ -113,18 +113,9 @@
113
113
  } catch {
114
114
  }
115
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;
116
+ void loadDrivers();
125
117
  };
126
- const detectAll = async () => {
127
- setDetecting(true);
118
+ const loadDrivers = async () => {
128
119
  setError(null);
129
120
  try {
130
121
  const stored = getStoredPostiz();
@@ -135,13 +126,11 @@
135
126
  headers["x-postiz-api-key"] = key;
136
127
  headers["x-postiz-base-url"] = url;
137
128
  }
138
- const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers });
129
+ const res = await fetch(`${apiBase}/drivers?team=${encodeURIComponent(teamId)}`, { headers });
139
130
  const json = await res.json();
140
- setProviders(Array.isArray(json.providers) ? json.providers : []);
131
+ setDrivers(Array.isArray(json.drivers) ? json.drivers : []);
141
132
  } catch (e) {
142
- setError(e?.message || "Failed to detect providers");
143
- } finally {
144
- setDetecting(false);
133
+ setError(e?.message || "Failed to load drivers");
145
134
  }
146
135
  };
147
136
  const loadManual = async () => {
@@ -154,7 +143,7 @@
154
143
  };
155
144
  const refresh = async () => {
156
145
  setLoading(true);
157
- await Promise.all([detectAll(), loadManual()]);
146
+ await Promise.all([loadDrivers(), loadManual()]);
158
147
  setLoading(false);
159
148
  };
160
149
  useEffect(() => {
@@ -179,43 +168,17 @@
179
168
  setManName("");
180
169
  setManUser("");
181
170
  setManToken("");
182
- await loadManual();
171
+ await Promise.all([loadDrivers(), loadManual()]);
183
172
  } catch (e) {
184
173
  setError(e?.message || "Failed to connect");
185
174
  } finally {
186
175
  setSaving(false);
187
176
  }
188
177
  };
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
- };
178
+ const connectedDrivers = useMemo(() => drivers.filter((d) => d.connected), [drivers]);
179
+ const disconnectedDrivers = useMemo(() => drivers.filter((d) => !d.connected), [drivers]);
180
+ const connectedCount = connectedDrivers.length;
181
+ const totalCount = drivers.length;
219
182
  return h(
220
183
  "div",
221
184
  { className: "space-y-3" },
@@ -229,11 +192,11 @@
229
192
  h(
230
193
  "div",
231
194
  null,
232
- h("div", { className: "text-sm font-medium", style: t.text }, "Connected Accounts"),
195
+ h("div", { className: "text-sm font-medium", style: t.text }, "Platform Drivers"),
233
196
  h(
234
197
  "div",
235
198
  { className: "mt-1 text-xs", style: t.faint },
236
- `${allProviders.length} provider${allProviders.length !== 1 ? "s" : ""} detected`
199
+ `${connectedCount}/${totalCount} platforms connected`
237
200
  )
238
201
  ),
239
202
  h(
@@ -241,15 +204,15 @@
241
204
  { className: "flex flex-wrap gap-2" },
242
205
  h(
243
206
  "button",
244
- { type: "button", onClick: () => void refresh(), style: t.btnGhost, disabled: detecting },
245
- detecting ? "Detecting\u2026" : "\u21BB Refresh"
207
+ { type: "button", onClick: () => void refresh(), style: t.btnGhost, disabled: loading },
208
+ loading ? "Loading\u2026" : "\u21BB Refresh"
246
209
  ),
247
210
  h(
248
211
  "button",
249
212
  { type: "button", onClick: () => setShowPostizSetup(!showPostizSetup), style: t.btnGhost },
250
213
  postizKey ? "\u2699 Postiz" : "+ Postiz"
251
214
  ),
252
- h("button", { type: "button", onClick: () => setShowManual(!showManual), style: t.btnGhost }, "+ Manual")
215
+ h("button", { type: "button", onClick: () => setShowManual(!showManual), style: t.btnGhost }, "+ Direct token")
253
216
  )
254
217
  ),
255
218
  error && h("div", { className: "mt-2 text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error)
@@ -262,7 +225,7 @@
262
225
  h(
263
226
  "div",
264
227
  { 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."
228
+ "Postiz manages OAuth connections to social platforms. Get your API key from Postiz Settings \u2192 Developers \u2192 Public API."
266
229
  ),
267
230
  h(
268
231
  "div",
@@ -295,15 +258,15 @@
295
258
  "div",
296
259
  { className: "mt-3 flex gap-2" },
297
260
  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")
261
+ h("button", { type: "button", onClick: savePostizConfig, style: t.btnPrimary }, "Save & Detect")
299
262
  )
300
263
  ),
301
- // ---- Manual account form ----
264
+ // ---- Manual token form ----
302
265
  showManual && h(
303
266
  "div",
304
267
  { style: t.card },
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."),
268
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Add direct API token"),
269
+ h("div", { className: "text-xs mb-3", style: t.faint }, "For platforms where you have your own API credentials. Token is encrypted at rest."),
307
270
  h(
308
271
  "div",
309
272
  { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
@@ -314,18 +277,14 @@
314
277
  h(
315
278
  "select",
316
279
  { value: manPlatform, onChange: (e) => setManPlatform(e.target.value), style: t.input },
317
- h("option", { value: "twitter" }, "Twitter / X"),
318
- h("option", { value: "instagram" }, "Instagram"),
319
- h("option", { value: "linkedin" }, "LinkedIn"),
320
- h("option", { value: "bluesky" }, "Bluesky"),
321
- h("option", { value: "mastodon" }, "Mastodon")
280
+ ...drivers.map((d) => h("option", { key: d.platform, value: d.platform }, d.label))
322
281
  )
323
282
  ),
324
283
  h(
325
284
  "div",
326
285
  null,
327
286
  h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Display name"),
328
- h("input", { value: manName, onChange: (e) => setManName(e.target.value), placeholder: "My X account", style: t.input })
287
+ h("input", { value: manName, onChange: (e) => setManName(e.target.value), placeholder: "My account", style: t.input })
329
288
  ),
330
289
  h(
331
290
  "div",
@@ -347,87 +306,168 @@
347
306
  h("button", { type: "button", onClick: () => void onManualConnect(), style: t.btnPrimary, disabled: saving }, saving ? "Saving\u2026" : "Connect")
348
307
  )
349
308
  ),
350
- // ---- Loading ----
351
- loading && h(
352
- "div",
353
- { style: t.card },
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(
309
+ // ---- Connected platforms ----
310
+ connectedDrivers.length > 0 && h(
358
311
  "div",
359
312
  { style: t.card },
360
313
  h(
361
314
  "div",
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."
315
+ { className: "flex items-center gap-2 mb-3" },
316
+ h("div", { className: "text-sm font-medium", style: t.text }, "Connected"),
317
+ h("span", { style: t.badge("rgba(74,222,128,0.7)") }, `${connectedCount}`)
318
+ ),
319
+ h(
320
+ "div",
321
+ { className: "space-y-2" },
322
+ ...connectedDrivers.map(
323
+ (d) => h(
324
+ "div",
325
+ { key: d.platform, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem" } },
326
+ d.avatar ? h("img", { src: d.avatar, alt: "", style: { width: 36, height: 36, borderRadius: "50%", objectFit: "cover" } }) : h("div", {
327
+ style: {
328
+ width: 36,
329
+ height: 36,
330
+ borderRadius: "50%",
331
+ background: "rgba(255,255,255,0.06)",
332
+ display: "flex",
333
+ alignItems: "center",
334
+ justifyContent: "center",
335
+ fontSize: "1.1rem"
336
+ }
337
+ }, d.icon),
338
+ h(
339
+ "div",
340
+ { style: { flex: 1, minWidth: 0 } },
341
+ h("div", { className: "text-sm font-medium", style: t.text }, d.displayName),
342
+ h(
343
+ "div",
344
+ { className: "text-xs", style: t.faint },
345
+ [d.username, d.platform].filter(Boolean).join(" \xB7 ")
346
+ )
347
+ ),
348
+ h(
349
+ "div",
350
+ { className: "flex items-center gap-2 shrink-0 flex-wrap" },
351
+ h("span", { style: t.badge(BACKEND_COLORS[d.backend] || BACKEND_COLORS.none) }, BACKEND_LABELS[d.backend] || d.backend),
352
+ d.capabilities.canPost && h("span", { style: t.capPill(true) }, "post"),
353
+ d.capabilities.canSchedule && h("span", { style: t.capPill(true) }, "schedule"),
354
+ d.capabilities.canUploadMedia && h("span", { style: t.capPill(true) }, "media"),
355
+ d.capabilities.maxLength && h("span", { style: t.capPill(false) }, `${d.capabilities.maxLength} chars`),
356
+ h("div", {
357
+ style: {
358
+ width: 8,
359
+ height: 8,
360
+ borderRadius: "50%",
361
+ background: "rgba(74,222,128,0.8)"
362
+ }
363
+ })
364
+ )
365
+ )
368
366
  )
369
367
  )
370
368
  ),
371
- !loading && Object.entries(grouped).map(
372
- ([type, items]) => h(
369
+ // ---- Disconnected platforms ----
370
+ disconnectedDrivers.length > 0 && h(
371
+ "div",
372
+ { style: t.card },
373
+ h(
373
374
  "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(
375
+ { className: "flex items-center gap-2 mb-3" },
376
+ h("div", { className: "text-sm font-medium", style: t.text }, "Available"),
377
+ h("span", { style: t.badge("rgba(100,100,100,0.5)") }, `${disconnectedDrivers.length}`)
378
+ ),
379
+ h(
380
+ "div",
381
+ { className: "text-xs mb-3", style: t.faint },
382
+ "Connect these via Postiz or by adding a direct API token above."
383
+ ),
384
+ h(
385
+ "div",
386
+ { className: "space-y-2" },
387
+ ...disconnectedDrivers.map(
388
+ (d) => h(
389
+ "div",
390
+ { key: d.platform, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem", opacity: 0.6 } },
391
+ h("div", {
392
+ style: {
393
+ width: 36,
394
+ height: 36,
395
+ borderRadius: "50%",
396
+ background: "rgba(255,255,255,0.04)",
397
+ display: "flex",
398
+ alignItems: "center",
399
+ justifyContent: "center",
400
+ fontSize: "1.1rem"
401
+ }
402
+ }, d.icon),
403
+ h(
386
404
  "div",
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", {
405
+ { style: { flex: 1, minWidth: 0 } },
406
+ h("div", { className: "text-sm font-medium", style: t.text }, d.label),
407
+ h("div", { className: "text-xs", style: t.faint }, "Not connected")
408
+ ),
409
+ h(
410
+ "div",
411
+ { className: "flex items-center gap-2 shrink-0" },
412
+ d.capabilities.maxLength && h("span", { style: t.capPill(false) }, `${d.capabilities.maxLength} chars`),
413
+ h("div", {
390
414
  style: {
391
- width: 32,
392
- height: 32,
415
+ width: 8,
416
+ height: 8,
393
417
  borderRadius: "50%",
394
- background: "rgba(255,255,255,0.06)",
395
- display: "flex",
396
- alignItems: "center",
397
- justifyContent: "center",
398
- fontSize: "1rem"
418
+ background: "rgba(100,100,100,0.4)"
399
419
  }
400
- }, PLATFORM_ICONS[p.platform] || "\u{1F517}"),
401
- // Info
402
- h(
403
- "div",
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
- )
411
- ),
412
- // Status + capabilities
420
+ })
421
+ )
422
+ )
423
+ )
424
+ )
425
+ ),
426
+ // ---- Manual accounts (if any exist beyond drivers) ----
427
+ manualAccounts.length > 0 && h(
428
+ "div",
429
+ { style: t.card },
430
+ h(
431
+ "div",
432
+ { className: "flex items-center gap-2 mb-3" },
433
+ h("div", { className: "text-sm font-medium", style: t.text }, "Stored tokens"),
434
+ h("span", { style: t.badge("rgba(251,191,36,0.7)") }, `${manualAccounts.length}`)
435
+ ),
436
+ h("div", { className: "text-xs mb-3", style: t.faint }, "Tokens stored locally, encrypted at rest. These feed into the direct backend for their platform driver."),
437
+ h(
438
+ "div",
439
+ { className: "space-y-2" },
440
+ ...manualAccounts.map(
441
+ (a) => h(
442
+ "div",
443
+ { key: a.id, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem" } },
444
+ h(
445
+ "div",
446
+ { style: { flex: 1, minWidth: 0 } },
447
+ h("div", { className: "text-sm", style: t.text }, a.displayName),
413
448
  h(
414
449
  "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
- })
450
+ { className: "text-xs", style: t.faint },
451
+ [a.platform, a.username].filter(Boolean).join(" \xB7 ")
426
452
  )
427
- )
453
+ ),
454
+ h("div", {
455
+ style: {
456
+ width: 8,
457
+ height: 8,
458
+ borderRadius: "50%",
459
+ background: a.isActive ? "rgba(74,222,128,0.8)" : "rgba(248,113,113,0.6)"
460
+ }
461
+ })
428
462
  )
429
463
  )
430
464
  )
465
+ ),
466
+ // ---- Loading ----
467
+ loading && drivers.length === 0 && h(
468
+ "div",
469
+ { style: t.card },
470
+ h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Detecting platform drivers\u2026")
431
471
  )
432
472
  );
433
473
  }
@@ -8,6 +8,7 @@
8
8
  const useMemo = R.useMemo;
9
9
  const useState = R.useState;
10
10
  const useCallback = R.useCallback;
11
+ const useRef = R.useRef;
11
12
  const t = {
12
13
  text: { color: "var(--ck-text-primary)" },
13
14
  muted: { color: "var(--ck-text-secondary)" },
@@ -53,15 +54,16 @@
53
54
  fontWeight: 700,
54
55
  cursor: "pointer"
55
56
  },
56
- pill: (active) => ({
57
+ pill: (active, connected) => ({
57
58
  background: active ? "rgba(99,179,237,0.16)" : "rgba(255,255,255,0.03)",
58
59
  border: `1px solid ${active ? "rgba(99,179,237,0.45)" : "var(--ck-border-subtle)"}`,
59
60
  borderRadius: "999px",
60
61
  padding: "0.25rem 0.55rem",
61
62
  fontSize: "0.8rem",
62
- color: active ? "rgba(210,235,255,0.95)" : "var(--ck-text-secondary)",
63
- cursor: "pointer",
64
- userSelect: "none"
63
+ color: active ? "rgba(210,235,255,0.95)" : connected ? "var(--ck-text-secondary)" : "var(--ck-text-tertiary)",
64
+ cursor: connected ? "pointer" : "default",
65
+ userSelect: "none",
66
+ opacity: connected ? 1 : 0.5
65
67
  }),
66
68
  statusBadge: (status) => {
67
69
  const colors = {
@@ -79,21 +81,46 @@
79
81
  fontWeight: 600,
80
82
  color: "white"
81
83
  };
82
- }
84
+ },
85
+ backendBadge: (backend) => {
86
+ const colors = {
87
+ postiz: "rgba(99,179,237,0.5)",
88
+ gateway: "rgba(134,239,172,0.5)",
89
+ direct: "rgba(251,191,36,0.5)"
90
+ };
91
+ return {
92
+ display: "inline-block",
93
+ background: colors[backend] || "rgba(100,100,100,0.3)",
94
+ borderRadius: "999px",
95
+ padding: "0.05rem 0.35rem",
96
+ fontSize: "0.6rem",
97
+ fontWeight: 600,
98
+ color: "white",
99
+ marginLeft: "0.25rem"
100
+ };
101
+ },
102
+ charWarn: (pct) => ({
103
+ color: pct > 100 ? "rgba(248,113,113,0.95)" : pct > 90 ? "rgba(251,191,36,0.9)" : "var(--ck-text-tertiary)",
104
+ fontSize: "0.75rem"
105
+ })
83
106
  };
84
107
  function ContentLibrary(props) {
85
108
  const teamId = String(props?.teamId || "default");
86
109
  const apiBase = useMemo(() => `/api/plugins/marketing`, []);
110
+ const [drivers, setDrivers] = useState([]);
87
111
  const [posts, setPosts] = useState([]);
88
- const [providers, setProviders] = useState([]);
89
112
  const [loading, setLoading] = useState(true);
90
113
  const [saving, setSaving] = useState(false);
91
114
  const [publishing, setPublishing] = useState(false);
92
115
  const [error, setError] = useState(null);
93
116
  const [success, setSuccess] = useState(null);
117
+ const [filterStatus, setFilterStatus] = useState("all");
94
118
  const [content, setContent] = useState("");
95
- const [selectedProviders, setSelectedProviders] = useState([]);
119
+ const [selectedPlatforms, setSelectedPlatforms] = useState([]);
96
120
  const [scheduledAt, setScheduledAt] = useState("");
121
+ const [mediaUrl, setMediaUrl] = useState("");
122
+ const [showMedia, setShowMedia] = useState(false);
123
+ const successTimeout = useRef(null);
97
124
  const postizHeaders = useMemo(() => {
98
125
  try {
99
126
  const stored = localStorage.getItem(`ck-postiz-${teamId}`);
@@ -110,44 +137,61 @@
110
137
  }
111
138
  return {};
112
139
  }, [teamId]);
113
- const loadPosts = useCallback(async () => {
140
+ const loadDrivers = useCallback(async () => {
114
141
  try {
115
- const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=25`);
142
+ const res = await fetch(`${apiBase}/drivers?team=${encodeURIComponent(teamId)}`, { headers: postizHeaders });
116
143
  const json = await res.json();
117
- setPosts(Array.isArray(json.data) ? json.data : []);
144
+ setDrivers(Array.isArray(json.drivers) ? json.drivers : []);
118
145
  } catch {
119
146
  }
120
- }, [apiBase, teamId]);
121
- const loadProviders = useCallback(async () => {
147
+ }, [apiBase, teamId, postizHeaders]);
148
+ const loadPosts = useCallback(async () => {
122
149
  try {
123
- const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers: postizHeaders });
150
+ const url = `${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=50`;
151
+ const res = await fetch(url);
124
152
  const json = await res.json();
125
- const detected = Array.isArray(json.providers) ? json.providers : [];
126
- setProviders(detected);
153
+ setPosts(Array.isArray(json.data) ? json.data : []);
127
154
  } catch {
128
155
  }
129
- }, [apiBase, teamId, postizHeaders]);
156
+ }, [apiBase, teamId]);
130
157
  useEffect(() => {
131
158
  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]
159
+ Promise.all([loadDrivers(), loadPosts()]).finally(() => setLoading(false));
160
+ }, [loadDrivers, loadPosts]);
161
+ const connectedDrivers = useMemo(() => drivers.filter((d) => d.connected), [drivers]);
162
+ const disconnectedDrivers = useMemo(() => drivers.filter((d) => !d.connected), [drivers]);
163
+ const togglePlatform = (platform) => {
164
+ const driver = drivers.find((d) => d.platform === platform);
165
+ if (!driver?.connected) return;
166
+ setSelectedPlatforms(
167
+ (prev) => prev.includes(platform) ? prev.filter((x) => x !== platform) : [...prev, platform]
137
168
  );
138
169
  };
170
+ const charLimit = useMemo(() => {
171
+ if (selectedPlatforms.length === 0) return void 0;
172
+ const limits = selectedPlatforms.map((p) => drivers.find((d) => d.platform === p)?.capabilities?.maxLength).filter((l) => l !== void 0);
173
+ return limits.length > 0 ? Math.min(...limits) : void 0;
174
+ }, [selectedPlatforms, drivers]);
175
+ const canSchedule = useMemo(() => {
176
+ return selectedPlatforms.some((p) => drivers.find((d) => d.platform === p)?.capabilities?.canSchedule);
177
+ }, [selectedPlatforms, drivers]);
178
+ const showSuccess = (msg) => {
179
+ setSuccess(msg);
180
+ if (successTimeout.current) clearTimeout(successTimeout.current);
181
+ successTimeout.current = setTimeout(() => setSuccess(null), 5e3);
182
+ };
139
183
  const onSaveDraft = async () => {
140
184
  if (!content.trim()) return;
141
185
  setSaving(true);
142
186
  setError(null);
143
187
  try {
144
- const platforms = selectedProviders.map((id) => providers.find((p) => p.id === id)?.platform).filter(Boolean);
188
+ const platforms = selectedPlatforms.length > 0 ? selectedPlatforms : ["draft"];
145
189
  const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}`, {
146
190
  method: "POST",
147
191
  headers: { "content-type": "application/json" },
148
192
  body: JSON.stringify({
149
193
  content,
150
- platforms: platforms.length > 0 ? platforms : ["draft"],
194
+ platforms,
151
195
  status: scheduledAt ? "scheduled" : "draft",
152
196
  scheduledAt: scheduledAt || void 0
153
197
  })
@@ -155,7 +199,9 @@
155
199
  if (!res.ok) throw new Error(`Save failed (${res.status})`);
156
200
  setContent("");
157
201
  setScheduledAt("");
158
- setSelectedProviders([]);
202
+ setSelectedPlatforms([]);
203
+ setMediaUrl("");
204
+ showSuccess("Draft saved!");
159
205
  await loadPosts();
160
206
  } catch (e) {
161
207
  setError(e?.message || "Failed to save");
@@ -164,40 +210,54 @@
164
210
  }
165
211
  };
166
212
  const onPublish = async () => {
167
- if (!content.trim() || selectedProviders.length === 0) return;
213
+ if (!content.trim() || selectedPlatforms.length === 0) return;
168
214
  setPublishing(true);
169
215
  setError(null);
170
216
  setSuccess(null);
171
- const postizProviders = selectedProviders.filter((id) => id.startsWith("postiz:"));
172
- const gatewayProviders = selectedProviders.filter((id) => id.startsWith("gateway:"));
173
217
  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)}`, {
180
- method: "POST",
181
- headers: { "content-type": "application/json", ...postizHeaders },
182
- body: JSON.stringify({
183
- content,
184
- integrationIds,
185
- scheduledAt: scheduledAt || void 0
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})`);
218
+ const res = await fetch(`${apiBase}/publish?team=${encodeURIComponent(teamId)}`, {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json", ...postizHeaders },
221
+ body: JSON.stringify({
222
+ content,
223
+ platforms: selectedPlatforms,
224
+ scheduledAt: scheduledAt || void 0,
225
+ mediaUrls: mediaUrl ? [mediaUrl] : void 0
226
+ })
227
+ });
228
+ const json = await res.json();
229
+ if (json.results) {
230
+ const succeeded = json.results.filter((r) => r.success);
231
+ const failed = json.results.filter((r) => !r.success);
232
+ if (failed.length > 0 && succeeded.length === 0) {
233
+ throw new Error(failed.map((f) => `${f.platform}: ${f.error}`).join("; "));
191
234
  }
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.");
235
+ const parts = [];
236
+ if (succeeded.length > 0) {
237
+ parts.push(`${scheduledAt ? "Scheduled" : "Published"} to ${succeeded.map((s) => s.platform).join(", ")}`);
238
+ }
239
+ if (failed.length > 0) {
240
+ parts.push(`Failed: ${failed.map((f) => `${f.platform} (${f.error})`).join(", ")}`);
241
+ }
242
+ showSuccess(parts.join(" \xB7 "));
195
243
  } else {
196
- setSuccess(scheduledAt ? "Scheduled via Postiz!" : "Published via Postiz!");
244
+ showSuccess(scheduledAt ? "Scheduled!" : "Published!");
197
245
  }
246
+ await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}`, {
247
+ method: "POST",
248
+ headers: { "content-type": "application/json" },
249
+ body: JSON.stringify({
250
+ content,
251
+ platforms: selectedPlatforms,
252
+ status: scheduledAt ? "scheduled" : "published",
253
+ scheduledAt: scheduledAt || void 0
254
+ })
255
+ }).catch(() => {
256
+ });
198
257
  setContent("");
199
258
  setScheduledAt("");
200
- setSelectedProviders([]);
259
+ setSelectedPlatforms([]);
260
+ setMediaUrl("");
201
261
  await loadPosts();
202
262
  } catch (e) {
203
263
  setError(e?.message || "Publish failed");
@@ -205,8 +265,12 @@
205
265
  setPublishing(false);
206
266
  }
207
267
  };
208
- const postizAvailable = providers.some((p) => p.type === "postiz");
209
- const hasSelection = selectedProviders.length > 0;
268
+ const hasConnected = connectedDrivers.length > 0;
269
+ const hasSelection = selectedPlatforms.length > 0;
270
+ const filteredPosts = useMemo(() => {
271
+ if (filterStatus === "all") return posts;
272
+ return posts.filter((p) => p.status === filterStatus);
273
+ }, [posts, filterStatus]);
210
274
  return h(
211
275
  "div",
212
276
  { className: "space-y-3" },
@@ -223,60 +287,103 @@
223
287
  onChange: (e) => setContent(e.target.value),
224
288
  placeholder: "Write your post\u2026",
225
289
  rows: 5,
226
- style: { ...t.input, resize: "vertical", minHeight: "110px" }
290
+ style: { ...t.input, resize: "vertical", minHeight: "110px", fontFamily: "inherit" }
227
291
  }),
228
- // Provider selector
229
- providers.length > 0 && h(
292
+ // Character count
293
+ charLimit && content.length > 0 && h(
294
+ "div",
295
+ { style: t.charWarn(content.length / charLimit * 100) },
296
+ `${content.length} / ${charLimit} characters`,
297
+ content.length > charLimit && " \u26A0 over limit"
298
+ ),
299
+ !charLimit && content.length > 0 && h("div", { className: "text-xs", style: t.faint }, `${content.length} chars`),
300
+ // Platform selector — connected
301
+ h(
230
302
  "div",
231
303
  null,
232
304
  h("div", { className: "text-xs font-medium mb-2", style: t.faint }, "Publish to"),
233
- h(
305
+ connectedDrivers.length > 0 ? h(
234
306
  "div",
235
307
  { className: "flex flex-wrap gap-2" },
236
- ...providers.filter((p) => p.isActive).map(
237
- (p) => h(
308
+ ...connectedDrivers.map(
309
+ (d) => h(
238
310
  "span",
239
311
  {
240
- key: p.id,
241
- onClick: () => toggleProvider(p.id),
242
- style: t.pill(selectedProviders.includes(p.id)),
312
+ key: d.platform,
313
+ onClick: () => togglePlatform(d.platform),
314
+ style: t.pill(selectedPlatforms.includes(d.platform), true),
243
315
  role: "button",
244
- tabIndex: 0
316
+ tabIndex: 0,
317
+ title: `${d.displayName} via ${d.backend}`
245
318
  },
246
- `${p.displayName}`
319
+ `${d.icon} ${d.label}`,
320
+ h("span", { style: t.backendBadge(d.backend) }, d.backend)
321
+ )
322
+ ),
323
+ ...disconnectedDrivers.map(
324
+ (d) => h("span", {
325
+ key: d.platform,
326
+ style: t.pill(false, false),
327
+ title: `${d.label} \u2014 not connected`
328
+ }, `${d.icon} ${d.label}`)
329
+ )
330
+ ) : h(
331
+ "div",
332
+ { className: "flex flex-wrap gap-2" },
333
+ ...drivers.map(
334
+ (d) => h(
335
+ "span",
336
+ { key: d.platform, style: t.pill(false, false), title: "Not connected" },
337
+ `${d.icon} ${d.label}`
247
338
  )
339
+ ),
340
+ h(
341
+ "div",
342
+ { className: "text-xs mt-1", style: t.faint },
343
+ "No platforms connected. Go to Accounts tab to set up Postiz or add accounts."
248
344
  )
249
345
  )
250
346
  ),
251
- // No providers hint
252
- providers.length === 0 && !loading && h(
347
+ // Media URL (collapsible)
348
+ h(
253
349
  "div",
254
- { className: "text-xs", style: t.faint },
255
- "No publishing targets detected. Go to Accounts tab to connect Postiz or add accounts."
350
+ null,
351
+ h("button", {
352
+ type: "button",
353
+ onClick: () => setShowMedia(!showMedia),
354
+ style: { ...t.btnGhost, padding: "0.3rem 0.55rem", fontSize: "0.8rem" }
355
+ }, showMedia ? "\u2212 Media" : "+ Media"),
356
+ showMedia && h(
357
+ "div",
358
+ { className: "mt-2" },
359
+ h("input", {
360
+ type: "url",
361
+ value: mediaUrl,
362
+ onChange: (e) => setMediaUrl(e.target.value),
363
+ placeholder: "Paste image or video URL\u2026",
364
+ style: t.input
365
+ })
366
+ )
256
367
  ),
257
- // Schedule
258
- h(
368
+ // Schedule (only if any selected platform supports it)
369
+ (canSchedule || !hasSelection) && h(
259
370
  "div",
260
371
  { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
261
372
  h(
262
373
  "div",
263
374
  null,
264
- h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Schedule (optional)"),
375
+ h(
376
+ "div",
377
+ { className: "text-xs font-medium mb-1", style: t.faint },
378
+ canSchedule ? "Schedule (optional)" : "Schedule (connect Postiz for scheduling)"
379
+ ),
265
380
  h("input", {
266
381
  type: "datetime-local",
267
382
  value: scheduledAt,
268
383
  onChange: (e) => setScheduledAt(e.target.value),
269
- style: t.input
384
+ style: { ...t.input, opacity: canSchedule || !hasSelection ? 1 : 0.5 },
385
+ disabled: hasSelection && !canSchedule
270
386
  })
271
- ),
272
- h(
273
- "div",
274
- { className: "flex items-end" },
275
- h(
276
- "div",
277
- { className: "text-xs", style: t.faint },
278
- content.length > 0 ? `${content.length} chars` : ""
279
- )
280
387
  )
281
388
  ),
282
389
  // Actions
@@ -289,17 +396,12 @@
289
396
  style: { ...t.btnGhost, opacity: saving ? 0.7 : 1 },
290
397
  disabled: saving || !content.trim()
291
398
  }, saving ? "Saving\u2026" : "Save draft"),
292
- postizAvailable && hasSelection && h("button", {
399
+ hasConnected && hasSelection && h("button", {
293
400
  type: "button",
294
401
  onClick: () => void onPublish(),
295
402
  style: { ...t.btnPublish, opacity: publishing ? 0.7 : 1 },
296
403
  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
- )
404
+ }, publishing ? "Publishing\u2026" : scheduledAt ? "\u23F1 Schedule" : "\u{1F4E4} Publish now")
303
405
  ),
304
406
  error && h("div", { className: "text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error),
305
407
  success && h("div", { className: "text-xs", style: { color: "rgba(74,222,128,0.9)" } }, success)
@@ -311,14 +413,36 @@
311
413
  { style: t.card },
312
414
  h(
313
415
  "div",
314
- { className: "flex items-center justify-between mb-2" },
416
+ { className: "flex items-center justify-between mb-3" },
315
417
  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")
418
+ h(
419
+ "div",
420
+ { className: "flex items-center gap-2" },
421
+ ...["all", "draft", "scheduled", "published", "failed"].map(
422
+ (s) => h("button", {
423
+ key: s,
424
+ type: "button",
425
+ onClick: () => setFilterStatus(s),
426
+ style: {
427
+ ...t.btnGhost,
428
+ padding: "0.2rem 0.45rem",
429
+ fontSize: "0.7rem",
430
+ background: filterStatus === s ? "rgba(99,179,237,0.12)" : void 0,
431
+ borderColor: filterStatus === s ? "rgba(99,179,237,0.35)" : void 0
432
+ }
433
+ }, s)
434
+ ),
435
+ h("button", { type: "button", onClick: () => void loadPosts(), style: { ...t.btnGhost, padding: "0.2rem 0.45rem", fontSize: "0.7rem" } }, "\u21BB")
436
+ )
317
437
  ),
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(
438
+ loading ? h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Loading\u2026") : filteredPosts.length === 0 ? h(
439
+ "div",
440
+ { className: "py-6 text-center text-sm", style: t.faint },
441
+ filterStatus === "all" ? "No posts yet. Compose your first post above!" : `No ${filterStatus} posts.`
442
+ ) : h(
319
443
  "div",
320
444
  { className: "space-y-2" },
321
- ...posts.map(
445
+ ...filteredPosts.map(
322
446
  (p) => h(
323
447
  "div",
324
448
  { key: p.id, style: { ...t.card, padding: "0.75rem" } },
@@ -333,11 +457,21 @@
333
457
  ),
334
458
  p.scheduledAt && h("div", { className: "text-xs", style: t.muted }, `\u23F1 ${new Date(p.scheduledAt).toLocaleString()}`)
335
459
  ),
336
- h("div", { className: "mt-2 whitespace-pre-wrap text-sm", style: t.text }, p.content),
460
+ h("div", {
461
+ className: "mt-2 whitespace-pre-wrap text-sm",
462
+ style: { ...t.text, maxHeight: "120px", overflow: "hidden", textOverflow: "ellipsis" }
463
+ }, p.content),
337
464
  p.platforms?.length > 0 && h(
338
465
  "div",
339
466
  { className: "mt-2 flex flex-wrap gap-1" },
340
- ...p.platforms.map((pl) => h("span", { key: pl, style: t.pill(true) }, pl))
467
+ ...p.platforms.map((pl) => {
468
+ const driver = drivers.find((d) => d.platform === pl);
469
+ return h(
470
+ "span",
471
+ { key: pl, style: t.pill(true, true) },
472
+ driver ? `${driver.icon} ${pl}` : pl
473
+ );
474
+ })
341
475
  )
342
476
  )
343
477
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/kitchen-plugin-marketing",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [