@jiggai/kitchen-plugin-marketing 0.2.6 → 0.2.7

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,186 @@
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 detectAll = async () => {
119
+ setDetecting(true);
61
120
  setError(null);
62
121
  try {
63
- const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
122
+ const headers = {};
123
+ if (postizKey) {
124
+ headers["x-postiz-api-key"] = postizKey;
125
+ headers["x-postiz-base-url"] = postizUrl;
126
+ }
127
+ const res = await fetch(`${apiBase}/providers?team=${encodeURIComponent(teamId)}`, { headers });
64
128
  const json = await res.json();
65
- setAccounts(Array.isArray(json.accounts) ? json.accounts : []);
129
+ setProviders(Array.isArray(json.providers) ? json.providers : []);
66
130
  } catch (e) {
67
- setError(e?.message || "Failed to load accounts");
131
+ setError(e?.message || "Failed to detect providers");
68
132
  } finally {
69
- setLoading(false);
133
+ setDetecting(false);
70
134
  }
71
135
  };
136
+ const loadManual = async () => {
137
+ try {
138
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
139
+ const json = await res.json();
140
+ setManualAccounts(Array.isArray(json.accounts) ? json.accounts : []);
141
+ } catch {
142
+ }
143
+ };
144
+ const refresh = async () => {
145
+ setLoading(true);
146
+ await Promise.all([detectAll(), loadManual()]);
147
+ setLoading(false);
148
+ };
72
149
  useEffect(() => {
73
150
  void refresh();
74
151
  }, [teamId]);
75
- const onConnect = async () => {
152
+ const onManualConnect = async () => {
76
153
  setSaving(true);
77
154
  setError(null);
78
155
  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();
156
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ platform: manPlatform,
161
+ displayName: manName || `${manPlatform} account`,
162
+ username: manUser || void 0,
163
+ credentials: { accessToken: manToken }
164
+ })
165
+ });
166
+ if (!res.ok) throw new Error(`Failed (${res.status})`);
167
+ setShowManual(false);
168
+ setManName("");
169
+ setManUser("");
170
+ setManToken("");
171
+ await loadManual();
102
172
  } catch (e) {
103
- setError(e?.message || "Failed to connect account");
173
+ setError(e?.message || "Failed to connect");
104
174
  } finally {
105
175
  setSaving(false);
106
176
  }
107
177
  };
178
+ const allProviders = useMemo(() => {
179
+ const combined = [...providers];
180
+ for (const ma of manualAccounts) {
181
+ combined.push({
182
+ id: `manual:${ma.id}`,
183
+ type: "manual",
184
+ platform: ma.platform,
185
+ displayName: ma.displayName,
186
+ username: ma.username,
187
+ isActive: ma.isActive,
188
+ capabilities: ["post"]
189
+ });
190
+ }
191
+ return combined;
192
+ }, [providers, manualAccounts]);
193
+ const grouped = useMemo(() => {
194
+ const g = {};
195
+ for (const p of allProviders) {
196
+ const key = p.type;
197
+ if (!g[key]) g[key] = [];
198
+ g[key].push(p);
199
+ }
200
+ return g;
201
+ }, [allProviders]);
202
+ const typeLabels = {
203
+ postiz: "Postiz",
204
+ gateway: "OpenClaw Channels",
205
+ skill: "Skills",
206
+ manual: "Manual"
207
+ };
108
208
  return h(
109
209
  "div",
110
210
  { className: "space-y-3" },
211
+ // ---- Header ----
111
212
  h(
112
213
  "div",
113
214
  { style: t.card },
@@ -117,22 +218,81 @@
117
218
  h(
118
219
  "div",
119
220
  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.")
221
+ h("div", { className: "text-sm font-medium", style: t.text }, "Connected Accounts"),
222
+ h(
223
+ "div",
224
+ { className: "mt-1 text-xs", style: t.faint },
225
+ `${allProviders.length} provider${allProviders.length !== 1 ? "s" : ""} detected`
226
+ )
122
227
  ),
123
228
  h(
124
229
  "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")
230
+ { className: "flex flex-wrap gap-2" },
231
+ h(
232
+ "button",
233
+ { type: "button", onClick: () => void refresh(), style: t.btnGhost, disabled: detecting },
234
+ detecting ? "Detecting\u2026" : "\u21BB Refresh"
235
+ ),
236
+ h(
237
+ "button",
238
+ { type: "button", onClick: () => setShowPostizSetup(!showPostizSetup), style: t.btnGhost },
239
+ postizKey ? "\u2699 Postiz" : "+ Postiz"
240
+ ),
241
+ h("button", { type: "button", onClick: () => setShowManual(!showManual), style: t.btnGhost }, "+ Manual")
128
242
  )
129
243
  ),
130
- error ? h("div", { className: "mt-2 text-sm", style: { color: "rgba(248,113,113,0.95)" } }, error) : null
244
+ error && h("div", { className: "mt-2 text-xs", style: { color: "rgba(248,113,113,0.95)" } }, error)
131
245
  ),
132
- open ? h(
246
+ // ---- Postiz setup ----
247
+ showPostizSetup && h(
133
248
  "div",
134
249
  { style: t.card },
135
- h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Connect account"),
250
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Postiz Configuration"),
251
+ h(
252
+ "div",
253
+ { className: "text-xs mb-3", style: t.faint },
254
+ "Connect Postiz to manage social accounts via their platform. Get your API key from Postiz Settings \u2192 Developers \u2192 Public API."
255
+ ),
256
+ h(
257
+ "div",
258
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
259
+ h(
260
+ "div",
261
+ null,
262
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "API Key"),
263
+ h("input", {
264
+ type: "password",
265
+ value: postizKey,
266
+ onChange: (e) => setPostizKey(e.target.value),
267
+ placeholder: "your-postiz-api-key",
268
+ style: t.input
269
+ })
270
+ ),
271
+ h(
272
+ "div",
273
+ null,
274
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Base URL"),
275
+ h("input", {
276
+ value: postizUrl,
277
+ onChange: (e) => setPostizUrl(e.target.value),
278
+ placeholder: "https://api.postiz.com/public/v1",
279
+ style: t.input
280
+ })
281
+ )
282
+ ),
283
+ h(
284
+ "div",
285
+ { className: "mt-3 flex gap-2" },
286
+ h("button", { type: "button", onClick: () => setShowPostizSetup(false), style: t.btnGhost }, "Cancel"),
287
+ h("button", { type: "button", onClick: savePostizConfig, style: t.btnPrimary }, postizKey ? "Save & Detect" : "Save")
288
+ )
289
+ ),
290
+ // ---- Manual account form ----
291
+ showManual && h(
292
+ "div",
293
+ { style: t.card },
294
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Add manual account"),
295
+ h("div", { className: "text-xs mb-3", style: t.faint }, "For direct API access without Postiz. You provide the token."),
136
296
  h(
137
297
  "div",
138
298
  { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
@@ -142,78 +302,117 @@
142
302
  h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Platform"),
143
303
  h(
144
304
  "select",
145
- {
146
- value: platform,
147
- onChange: (e) => setPlatform(e.target.value),
148
- style: t.input
149
- },
305
+ { value: manPlatform, onChange: (e) => setManPlatform(e.target.value), style: t.input },
150
306
  h("option", { value: "twitter" }, "Twitter / X"),
151
307
  h("option", { value: "instagram" }, "Instagram"),
152
- h("option", { value: "linkedin" }, "LinkedIn")
308
+ h("option", { value: "linkedin" }, "LinkedIn"),
309
+ h("option", { value: "bluesky" }, "Bluesky"),
310
+ h("option", { value: "mastodon" }, "Mastodon")
153
311
  )
154
312
  ),
155
313
  h(
156
314
  "div",
157
315
  null,
158
316
  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
- })
317
+ h("input", { value: manName, onChange: (e) => setManName(e.target.value), placeholder: "My X account", style: t.input })
165
318
  ),
166
319
  h(
167
320
  "div",
168
321
  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
- })
322
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Username"),
323
+ h("input", { value: manUser, onChange: (e) => setManUser(e.target.value), placeholder: "@handle", style: t.input })
176
324
  ),
177
325
  h(
178
326
  "div",
179
327
  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
- })
328
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Access token"),
329
+ h("input", { type: "password", value: manToken, onChange: (e) => setManToken(e.target.value), placeholder: "token\u2026", style: t.input })
187
330
  )
188
331
  ),
189
332
  h(
190
333
  "div",
191
334
  { 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")
335
+ h("button", { type: "button", onClick: () => setShowManual(false), style: t.btnGhost }, "Cancel"),
336
+ h("button", { type: "button", onClick: () => void onManualConnect(), style: t.btnPrimary, disabled: saving }, saving ? "Saving\u2026" : "Connect")
194
337
  )
195
- ) : null,
196
- h(
338
+ ),
339
+ // ---- Loading ----
340
+ loading && h(
341
+ "div",
342
+ { style: t.card },
343
+ h("div", { className: "py-6 text-center text-sm", style: t.faint }, "Detecting providers\u2026")
344
+ ),
345
+ // ---- Provider groups ----
346
+ !loading && allProviders.length === 0 && h(
197
347
  "div",
198
348
  { 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(
349
+ h(
201
350
  "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(
351
+ { className: "py-6 text-center space-y-2" },
352
+ h("div", { className: "text-sm", style: t.faint }, "No providers detected"),
353
+ h(
354
+ "div",
355
+ { className: "text-xs", style: t.faint },
356
+ "Connect Postiz for full social media management, or add accounts manually."
357
+ )
358
+ )
359
+ ),
360
+ !loading && Object.entries(grouped).map(
361
+ ([type, items]) => h(
362
+ "div",
363
+ { key: type, style: t.card },
364
+ h(
365
+ "div",
366
+ { className: "flex items-center gap-2 mb-3" },
367
+ h("div", { className: "text-sm font-medium", style: t.text }, typeLabels[type] || type),
368
+ h("span", { style: t.badge(TYPE_COLORS[type] || "rgba(100,100,100,0.6)") }, `${items.length}`)
369
+ ),
370
+ h(
371
+ "div",
372
+ { className: "space-y-2" },
373
+ ...items.map(
374
+ (p) => h(
208
375
  "div",
209
- { className: "flex items-center justify-between gap-2" },
376
+ { key: p.id, style: { ...t.card, padding: "0.75rem", display: "flex", alignItems: "center", gap: "0.75rem" } },
377
+ // Avatar or platform icon
378
+ p.avatar ? h("img", { src: p.avatar, alt: "", style: { width: 32, height: 32, borderRadius: "50%", objectFit: "cover" } }) : h("div", {
379
+ style: {
380
+ width: 32,
381
+ height: 32,
382
+ borderRadius: "50%",
383
+ background: "rgba(255,255,255,0.06)",
384
+ display: "flex",
385
+ alignItems: "center",
386
+ justifyContent: "center",
387
+ fontSize: "1rem"
388
+ }
389
+ }, PLATFORM_ICONS[p.platform] || "\u{1F517}"),
390
+ // Info
210
391
  h(
211
392
  "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}` : ""}`)
393
+ { style: { flex: 1, minWidth: 0 } },
394
+ h("div", { className: "text-sm font-medium", style: t.text }, p.displayName),
395
+ h(
396
+ "div",
397
+ { className: "text-xs", style: t.faint },
398
+ [p.platform, p.username].filter(Boolean).join(" \xB7 ")
399
+ )
215
400
  ),
216
- h("div", { className: "text-xs", style: a.isActive ? t.muted : t.faint }, a.isActive ? "active" : "disabled")
401
+ // Status + capabilities
402
+ h(
403
+ "div",
404
+ { className: "flex items-center gap-2 shrink-0" },
405
+ p.capabilities?.includes("schedule") && h("span", { className: "text-xs", style: t.faint }, "\u23F1"),
406
+ p.capabilities?.includes("post") && h("span", { className: "text-xs", style: t.faint }, "\u{1F4E4}"),
407
+ h("div", {
408
+ style: {
409
+ width: 8,
410
+ height: 8,
411
+ borderRadius: "50%",
412
+ background: p.isActive ? "rgba(74,222,128,0.8)" : "rgba(248,113,113,0.6)"
413
+ }
414
+ })
415
+ )
217
416
  )
218
417
  )
219
418
  )
@@ -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.7",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [