@jiggai/kitchen-plugin-marketing 0.2.5 → 0.2.6

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.
@@ -0,0 +1,377 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc2) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc2 = __getOwnPropDesc(from, key)) || desc2.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/api/handler.ts
30
+ var handler_exports = {};
31
+ __export(handler_exports, {
32
+ handleRequest: () => handleRequest
33
+ });
34
+ module.exports = __toCommonJS(handler_exports);
35
+ var import_drizzle_orm2 = require("drizzle-orm");
36
+ var import_crypto2 = require("crypto");
37
+
38
+ // src/db/index.ts
39
+ var import_better_sqlite3 = __toESM(require("better-sqlite3"));
40
+ var import_better_sqlite32 = require("drizzle-orm/better-sqlite3");
41
+
42
+ // src/db/schema.ts
43
+ var schema_exports = {};
44
+ __export(schema_exports, {
45
+ accountMetrics: () => accountMetrics,
46
+ accountMetricsRelations: () => accountMetricsRelations,
47
+ media: () => media,
48
+ postMetrics: () => postMetrics,
49
+ postMetricsRelations: () => postMetricsRelations,
50
+ posts: () => posts,
51
+ postsRelations: () => postsRelations,
52
+ socialAccounts: () => socialAccounts,
53
+ socialAccountsRelations: () => socialAccountsRelations,
54
+ templates: () => templates,
55
+ webhooks: () => webhooks
56
+ });
57
+ var import_sqlite_core = require("drizzle-orm/sqlite-core");
58
+ var import_drizzle_orm = require("drizzle-orm");
59
+ var posts = (0, import_sqlite_core.sqliteTable)("posts", {
60
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
61
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
62
+ content: (0, import_sqlite_core.text)("content").notNull(),
63
+ platforms: (0, import_sqlite_core.text)("platforms").notNull(),
64
+ // JSON array
65
+ status: (0, import_sqlite_core.text)("status").notNull(),
66
+ // draft, scheduled, published, failed
67
+ scheduledAt: (0, import_sqlite_core.text)("scheduled_at"),
68
+ // ISO string
69
+ publishedAt: (0, import_sqlite_core.text)("published_at"),
70
+ // ISO string
71
+ tags: (0, import_sqlite_core.text)("tags"),
72
+ // JSON array
73
+ mediaIds: (0, import_sqlite_core.text)("media_ids"),
74
+ // JSON array
75
+ templateId: (0, import_sqlite_core.text)("template_id"),
76
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
77
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull(),
78
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
79
+ });
80
+ var media = (0, import_sqlite_core.sqliteTable)("media", {
81
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
82
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
83
+ filename: (0, import_sqlite_core.text)("filename").notNull(),
84
+ originalName: (0, import_sqlite_core.text)("original_name").notNull(),
85
+ mimeType: (0, import_sqlite_core.text)("mime_type").notNull(),
86
+ size: (0, import_sqlite_core.integer)("size").notNull(),
87
+ width: (0, import_sqlite_core.integer)("width"),
88
+ height: (0, import_sqlite_core.integer)("height"),
89
+ alt: (0, import_sqlite_core.text)("alt"),
90
+ tags: (0, import_sqlite_core.text)("tags"),
91
+ // JSON array
92
+ url: (0, import_sqlite_core.text)("url").notNull(),
93
+ thumbnailUrl: (0, import_sqlite_core.text)("thumbnail_url"),
94
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
95
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
96
+ });
97
+ var templates = (0, import_sqlite_core.sqliteTable)("templates", {
98
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
99
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
100
+ name: (0, import_sqlite_core.text)("name").notNull(),
101
+ content: (0, import_sqlite_core.text)("content").notNull(),
102
+ variables: (0, import_sqlite_core.text)("variables"),
103
+ // JSON array of variable definitions
104
+ tags: (0, import_sqlite_core.text)("tags"),
105
+ // JSON array
106
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
107
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull(),
108
+ createdBy: (0, import_sqlite_core.text)("created_by").notNull()
109
+ });
110
+ var socialAccounts = (0, import_sqlite_core.sqliteTable)("social_accounts", {
111
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
112
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
113
+ platform: (0, import_sqlite_core.text)("platform").notNull(),
114
+ // twitter, linkedin, instagram, etc.
115
+ displayName: (0, import_sqlite_core.text)("display_name").notNull(),
116
+ username: (0, import_sqlite_core.text)("username"),
117
+ avatar: (0, import_sqlite_core.text)("avatar"),
118
+ isActive: (0, import_sqlite_core.integer)("is_active", { mode: "boolean" }).notNull().default(true),
119
+ credentials: (0, import_sqlite_core.blob)("credentials").notNull(),
120
+ // Encrypted JSON
121
+ settings: (0, import_sqlite_core.text)("settings"),
122
+ // JSON object
123
+ lastSync: (0, import_sqlite_core.text)("last_sync"),
124
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
125
+ updatedAt: (0, import_sqlite_core.text)("updated_at").notNull()
126
+ });
127
+ var postMetrics = (0, import_sqlite_core.sqliteTable)("post_metrics", {
128
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
129
+ postId: (0, import_sqlite_core.text)("post_id").notNull(),
130
+ platform: (0, import_sqlite_core.text)("platform").notNull(),
131
+ impressions: (0, import_sqlite_core.integer)("impressions").default(0),
132
+ likes: (0, import_sqlite_core.integer)("likes").default(0),
133
+ shares: (0, import_sqlite_core.integer)("shares").default(0),
134
+ comments: (0, import_sqlite_core.integer)("comments").default(0),
135
+ clicks: (0, import_sqlite_core.integer)("clicks").default(0),
136
+ engagementRate: (0, import_sqlite_core.text)("engagement_rate"),
137
+ // Stored as string to avoid float precision issues
138
+ syncedAt: (0, import_sqlite_core.text)("synced_at").notNull()
139
+ });
140
+ var accountMetrics = (0, import_sqlite_core.sqliteTable)("account_metrics", {
141
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
142
+ accountId: (0, import_sqlite_core.text)("account_id").notNull(),
143
+ date: (0, import_sqlite_core.text)("date").notNull(),
144
+ // YYYY-MM-DD format
145
+ followers: (0, import_sqlite_core.integer)("followers").default(0),
146
+ following: (0, import_sqlite_core.integer)("following").default(0),
147
+ posts: (0, import_sqlite_core.integer)("posts").default(0),
148
+ engagement: (0, import_sqlite_core.integer)("engagement").default(0),
149
+ reach: (0, import_sqlite_core.integer)("reach").default(0),
150
+ syncedAt: (0, import_sqlite_core.text)("synced_at").notNull()
151
+ });
152
+ var webhooks = (0, import_sqlite_core.sqliteTable)("webhooks", {
153
+ id: (0, import_sqlite_core.text)("id").primaryKey(),
154
+ teamId: (0, import_sqlite_core.text)("team_id").notNull(),
155
+ url: (0, import_sqlite_core.text)("url").notNull(),
156
+ events: (0, import_sqlite_core.text)("events").notNull(),
157
+ // JSON array
158
+ secret: (0, import_sqlite_core.text)("secret"),
159
+ isActive: (0, import_sqlite_core.integer)("is_active", { mode: "boolean" }).notNull().default(true),
160
+ createdAt: (0, import_sqlite_core.text)("created_at").notNull(),
161
+ lastTriggered: (0, import_sqlite_core.text)("last_triggered")
162
+ });
163
+ var postsRelations = (0, import_drizzle_orm.relations)(posts, ({ many }) => ({
164
+ metrics: many(postMetrics)
165
+ }));
166
+ var postMetricsRelations = (0, import_drizzle_orm.relations)(postMetrics, ({ one }) => ({
167
+ post: one(posts, {
168
+ fields: [postMetrics.postId],
169
+ references: [posts.id]
170
+ })
171
+ }));
172
+ var socialAccountsRelations = (0, import_drizzle_orm.relations)(socialAccounts, ({ many }) => ({
173
+ metrics: many(accountMetrics)
174
+ }));
175
+ var accountMetricsRelations = (0, import_drizzle_orm.relations)(accountMetrics, ({ one }) => ({
176
+ account: one(socialAccounts, {
177
+ fields: [accountMetrics.accountId],
178
+ references: [socialAccounts.id]
179
+ })
180
+ }));
181
+
182
+ // src/db/index.ts
183
+ var import_migrator = require("drizzle-orm/better-sqlite3/migrator");
184
+ var import_crypto = require("crypto");
185
+ function createDatabase(teamId) {
186
+ const dbPath = process.env.KITCHEN_PLUGIN_DB_PATH || "./data";
187
+ const teamDbFile = `${dbPath}/marketing-${teamId}.db`;
188
+ const sqlite = new import_better_sqlite3.default(teamDbFile);
189
+ const db = (0, import_better_sqlite32.drizzle)(sqlite, { schema: schema_exports });
190
+ return { db, sqlite };
191
+ }
192
+ var ENCRYPTION_KEY = process.env.KITCHEN_ENCRYPTION_KEY || "fallback-key-change-in-production";
193
+ function encryptCredentials(credentials) {
194
+ const plaintext = JSON.stringify(credentials);
195
+ const hash = (0, import_crypto.createHash)("sha256").update(ENCRYPTION_KEY).digest();
196
+ const cipher = (0, import_crypto.createCipher)("aes-256-cbc", hash);
197
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
198
+ encrypted += cipher.final("hex");
199
+ return Buffer.from(encrypted, "hex");
200
+ }
201
+ function initializeDatabase(teamId) {
202
+ const { db, sqlite } = createDatabase(teamId);
203
+ try {
204
+ (0, import_migrator.migrate)(db, { migrationsFolder: "./db/migrations" });
205
+ } catch (error) {
206
+ console.warn("Migration warning:", error.message);
207
+ }
208
+ return { db, sqlite };
209
+ }
210
+
211
+ // src/api/handler.ts
212
+ function apiError(status, error, message, details) {
213
+ const payload = { error, message, details };
214
+ return { status, data: payload };
215
+ }
216
+ function parsePagination(query) {
217
+ const limit = Math.min(parseInt(query.limit || "20", 10) || 20, 100);
218
+ const offset = parseInt(query.offset || "0", 10) || 0;
219
+ return { limit, offset };
220
+ }
221
+ function getTeamId(req) {
222
+ return req.query.team || req.query.teamId || req.headers["x-team-id"] || "default";
223
+ }
224
+ function getUserId(req) {
225
+ return req.headers["x-user-id"] || "system";
226
+ }
227
+ async function handleRequest(req, _ctx) {
228
+ const teamId = getTeamId(req);
229
+ if (req.path === "/posts" && req.method === "GET") {
230
+ try {
231
+ const { db } = initializeDatabase(teamId);
232
+ const { limit, offset } = parsePagination(req.query);
233
+ const conditions = [(0, import_drizzle_orm2.eq)(posts.teamId, teamId)];
234
+ if (req.query.status) {
235
+ conditions.push((0, import_drizzle_orm2.eq)(posts.status, String(req.query.status)));
236
+ }
237
+ if (req.query.platform) {
238
+ conditions.push((0, import_drizzle_orm2.like)(posts.platforms, `%"${req.query.platform}"%`));
239
+ }
240
+ const totalResult = await db.select({ count: import_drizzle_orm2.sql`count(*)` }).from(posts).where((0, import_drizzle_orm2.and)(...conditions));
241
+ const total = totalResult[0]?.count ?? 0;
242
+ const posts2 = await db.select().from(posts).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(posts.createdAt)).limit(limit).offset(offset);
243
+ const transformed = posts2.map((post) => ({
244
+ id: post.id,
245
+ content: post.content,
246
+ platforms: JSON.parse(post.platforms || "[]"),
247
+ status: post.status,
248
+ scheduledAt: post.scheduledAt || void 0,
249
+ publishedAt: post.publishedAt || void 0,
250
+ tags: JSON.parse(post.tags || "[]"),
251
+ mediaIds: JSON.parse(post.mediaIds || "[]"),
252
+ templateId: post.templateId || void 0,
253
+ createdAt: post.createdAt,
254
+ updatedAt: post.updatedAt,
255
+ createdBy: post.createdBy
256
+ }));
257
+ const payload = {
258
+ data: transformed,
259
+ total,
260
+ offset,
261
+ limit,
262
+ hasMore: offset + limit < total
263
+ };
264
+ return { status: 200, data: payload };
265
+ } catch (error) {
266
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
267
+ }
268
+ }
269
+ if (req.path === "/posts" && req.method === "POST") {
270
+ try {
271
+ const userId = getUserId(req);
272
+ const body = req.body || {};
273
+ if (!body.content || !Array.isArray(body.platforms) || body.platforms.length === 0) {
274
+ return apiError(400, "VALIDATION_ERROR", "Content and platforms are required");
275
+ }
276
+ const { db } = initializeDatabase(teamId);
277
+ const now = (/* @__PURE__ */ new Date()).toISOString();
278
+ const newPost = {
279
+ id: (0, import_crypto2.randomUUID)(),
280
+ teamId,
281
+ content: body.content,
282
+ platforms: JSON.stringify(body.platforms),
283
+ status: body.status || "draft",
284
+ scheduledAt: body.scheduledAt || null,
285
+ publishedAt: null,
286
+ tags: JSON.stringify(body.tags || []),
287
+ mediaIds: JSON.stringify(body.mediaIds || []),
288
+ templateId: body.templateId || null,
289
+ createdAt: now,
290
+ updatedAt: now,
291
+ createdBy: userId
292
+ };
293
+ await db.insert(posts).values(newPost);
294
+ const response = {
295
+ id: newPost.id,
296
+ content: newPost.content,
297
+ platforms: JSON.parse(newPost.platforms),
298
+ status: newPost.status,
299
+ scheduledAt: newPost.scheduledAt || void 0,
300
+ publishedAt: void 0,
301
+ tags: JSON.parse(newPost.tags),
302
+ mediaIds: JSON.parse(newPost.mediaIds),
303
+ templateId: newPost.templateId || void 0,
304
+ createdAt: newPost.createdAt,
305
+ updatedAt: newPost.updatedAt,
306
+ createdBy: newPost.createdBy
307
+ };
308
+ return { status: 201, data: response };
309
+ } catch (error) {
310
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
311
+ }
312
+ }
313
+ if (req.path === "/accounts" && req.method === "GET") {
314
+ try {
315
+ const { db } = initializeDatabase(teamId);
316
+ const accounts = await db.select().from(socialAccounts).where((0, import_drizzle_orm2.eq)(socialAccounts.teamId, teamId)).orderBy((0, import_drizzle_orm2.desc)(socialAccounts.createdAt));
317
+ const response = accounts.map((account) => ({
318
+ id: account.id,
319
+ platform: account.platform,
320
+ displayName: account.displayName,
321
+ username: account.username || void 0,
322
+ avatar: account.avatar || void 0,
323
+ isActive: account.isActive,
324
+ settings: JSON.parse(account.settings || "{}"),
325
+ lastSync: account.lastSync || void 0,
326
+ createdAt: account.createdAt,
327
+ updatedAt: account.updatedAt
328
+ }));
329
+ return { status: 200, data: { accounts: response } };
330
+ } catch (error) {
331
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
332
+ }
333
+ }
334
+ if (req.path === "/accounts" && req.method === "POST") {
335
+ try {
336
+ const body = req.body || {};
337
+ if (!body.platform || !body.displayName || !body.credentials) {
338
+ return apiError(400, "VALIDATION_ERROR", "platform, displayName, and credentials are required");
339
+ }
340
+ const { db } = initializeDatabase(teamId);
341
+ const now = (/* @__PURE__ */ new Date()).toISOString();
342
+ const newAccount = {
343
+ id: (0, import_crypto2.randomUUID)(),
344
+ teamId,
345
+ platform: body.platform,
346
+ displayName: body.displayName,
347
+ username: body.username || null,
348
+ avatar: null,
349
+ isActive: true,
350
+ credentials: encryptCredentials(body.credentials),
351
+ settings: JSON.stringify(body.settings || {}),
352
+ lastSync: null,
353
+ createdAt: now,
354
+ updatedAt: now
355
+ };
356
+ await db.insert(socialAccounts).values(newAccount);
357
+ const response = {
358
+ id: newAccount.id,
359
+ platform: newAccount.platform,
360
+ displayName: newAccount.displayName,
361
+ username: newAccount.username || void 0,
362
+ isActive: newAccount.isActive,
363
+ settings: JSON.parse(newAccount.settings),
364
+ createdAt: newAccount.createdAt,
365
+ updatedAt: newAccount.updatedAt
366
+ };
367
+ return { status: 201, data: response };
368
+ } catch (error) {
369
+ return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
370
+ }
371
+ }
372
+ return apiError(501, "NOT_IMPLEMENTED", `No handler for ${req.method} ${req.path}`);
373
+ }
374
+ // Annotate the CommonJS export names for ESM import in node:
375
+ 0 && (module.exports = {
376
+ handleRequest
377
+ });
@@ -4,6 +4,9 @@
4
4
  const R = window.React;
5
5
  if (!R) return;
6
6
  const h = R.createElement;
7
+ const useEffect = R.useEffect;
8
+ const useMemo = R.useMemo;
9
+ const useState = R.useState;
7
10
  const t = {
8
11
  text: { color: "var(--ck-text-primary)" },
9
12
  muted: { color: "var(--ck-text-secondary)" },
@@ -14,48 +17,207 @@
14
17
  borderRadius: "10px",
15
18
  padding: "1rem"
16
19
  },
17
- platformBtn: {
20
+ input: {
18
21
  background: "rgba(255,255,255,0.03)",
19
22
  border: "1px solid var(--ck-border-subtle)",
20
23
  borderRadius: "10px",
21
- cursor: "pointer",
22
- transition: "border-color 0.15s, background 0.15s",
23
- padding: "1rem",
24
- textAlign: "center"
24
+ padding: "0.6rem 0.75rem",
25
+ color: "var(--ck-text-primary)",
26
+ width: "100%"
27
+ },
28
+ btnPrimary: {
29
+ background: "var(--ck-accent-red)",
30
+ border: "1px solid rgba(255,255,255,0.08)",
31
+ borderRadius: "10px",
32
+ padding: "0.6rem 0.85rem",
33
+ color: "white",
34
+ fontWeight: 700,
35
+ cursor: "pointer"
36
+ },
37
+ btnGhost: {
38
+ background: "rgba(255,255,255,0.03)",
39
+ border: "1px solid var(--ck-border-subtle)",
40
+ borderRadius: "10px",
41
+ padding: "0.6rem 0.85rem",
42
+ color: "var(--ck-text-primary)",
43
+ fontWeight: 600,
44
+ cursor: "pointer"
25
45
  }
26
46
  };
27
- const platforms = [
28
- { icon: "\u{1D54F}", name: "Twitter / X" },
29
- { icon: "\u{1F4F7}", name: "Instagram" },
30
- { icon: "\u{1F3AC}", name: "YouTube" },
31
- { icon: "\u{1F4BC}", name: "LinkedIn" }
32
- ];
33
- function Accounts() {
47
+ function Accounts(props) {
48
+ const teamId = String(props?.teamId || "default");
49
+ const apiBase = useMemo(() => `/api/plugins/marketing`, []);
50
+ const [accounts, setAccounts] = useState([]);
51
+ 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);
58
+ const [error, setError] = useState(null);
59
+ const refresh = async () => {
60
+ setLoading(true);
61
+ setError(null);
62
+ try {
63
+ const res = await fetch(`${apiBase}/accounts?team=${encodeURIComponent(teamId)}`);
64
+ const json = await res.json();
65
+ setAccounts(Array.isArray(json.accounts) ? json.accounts : []);
66
+ } catch (e) {
67
+ setError(e?.message || "Failed to load accounts");
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+ useEffect(() => {
73
+ void refresh();
74
+ }, [teamId]);
75
+ const onConnect = async () => {
76
+ setSaving(true);
77
+ setError(null);
78
+ 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();
102
+ } catch (e) {
103
+ setError(e?.message || "Failed to connect account");
104
+ } finally {
105
+ setSaving(false);
106
+ }
107
+ };
34
108
  return h(
35
109
  "div",
36
110
  { className: "space-y-3" },
37
111
  h(
38
112
  "div",
39
113
  { style: t.card },
40
- h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Connect Account"),
41
114
  h(
42
115
  "div",
43
- { style: { display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: "0.5rem" } },
44
- ...platforms.map(
45
- (p) => h(
46
- "div",
47
- { key: p.name, style: t.platformBtn },
48
- h("div", { className: "text-xl mb-1" }, p.icon),
49
- h("div", { className: "text-xs font-medium", style: t.text }, p.name)
116
+ { className: "flex items-start justify-between gap-2" },
117
+ h(
118
+ "div",
119
+ 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.")
122
+ ),
123
+ h(
124
+ "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")
128
+ )
129
+ ),
130
+ error ? h("div", { className: "mt-2 text-sm", style: { color: "rgba(248,113,113,0.95)" } }, error) : null
131
+ ),
132
+ open ? h(
133
+ "div",
134
+ { style: t.card },
135
+ h("div", { className: "text-sm font-medium mb-3", style: t.text }, "Connect account"),
136
+ h(
137
+ "div",
138
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
139
+ h(
140
+ "div",
141
+ null,
142
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Platform"),
143
+ h(
144
+ "select",
145
+ {
146
+ value: platform,
147
+ onChange: (e) => setPlatform(e.target.value),
148
+ style: t.input
149
+ },
150
+ h("option", { value: "twitter" }, "Twitter / X"),
151
+ h("option", { value: "instagram" }, "Instagram"),
152
+ h("option", { value: "linkedin" }, "LinkedIn")
50
153
  )
154
+ ),
155
+ h(
156
+ "div",
157
+ null,
158
+ 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
+ })
165
+ ),
166
+ h(
167
+ "div",
168
+ 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
+ })
176
+ ),
177
+ h(
178
+ "div",
179
+ 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
+ })
51
187
  )
188
+ ),
189
+ h(
190
+ "div",
191
+ { 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")
52
194
  )
53
- ),
195
+ ) : null,
54
196
  h(
55
197
  "div",
56
198
  { style: t.card },
57
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Connected Accounts"),
58
- h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No accounts connected yet.")
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(
201
+ "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(
208
+ "div",
209
+ { className: "flex items-center justify-between gap-2" },
210
+ h(
211
+ "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}` : ""}`)
215
+ ),
216
+ h("div", { className: "text-xs", style: a.isActive ? t.muted : t.faint }, a.isActive ? "active" : "disabled")
217
+ )
218
+ )
219
+ )
220
+ )
59
221
  )
60
222
  );
61
223
  }
@@ -4,6 +4,9 @@
4
4
  const R = window.React;
5
5
  if (!R) return;
6
6
  const h = R.createElement;
7
+ const useEffect = R.useEffect;
8
+ const useMemo = R.useMemo;
9
+ const useState = R.useState;
7
10
  const t = {
8
11
  text: { color: "var(--ck-text-primary)" },
9
12
  muted: { color: "var(--ck-text-secondary)" },
@@ -13,31 +16,208 @@
13
16
  border: "1px solid var(--ck-border-subtle)",
14
17
  borderRadius: "10px",
15
18
  padding: "1rem"
16
- }
19
+ },
20
+ input: {
21
+ background: "rgba(255,255,255,0.03)",
22
+ border: "1px solid var(--ck-border-subtle)",
23
+ borderRadius: "10px",
24
+ padding: "0.6rem 0.75rem",
25
+ color: "var(--ck-text-primary)",
26
+ width: "100%"
27
+ },
28
+ btnPrimary: {
29
+ background: "var(--ck-accent-red)",
30
+ border: "1px solid rgba(255,255,255,0.08)",
31
+ borderRadius: "10px",
32
+ padding: "0.6rem 0.85rem",
33
+ color: "white",
34
+ fontWeight: 700,
35
+ cursor: "pointer"
36
+ },
37
+ btnGhost: {
38
+ background: "rgba(255,255,255,0.03)",
39
+ border: "1px solid var(--ck-border-subtle)",
40
+ borderRadius: "10px",
41
+ padding: "0.6rem 0.85rem",
42
+ color: "var(--ck-text-primary)",
43
+ fontWeight: 600,
44
+ cursor: "pointer"
45
+ },
46
+ pill: (active) => ({
47
+ background: active ? "rgba(99,179,237,0.16)" : "rgba(255,255,255,0.03)",
48
+ border: `1px solid ${active ? "rgba(99,179,237,0.45)" : "var(--ck-border-subtle)"}`,
49
+ borderRadius: "999px",
50
+ padding: "0.25rem 0.55rem",
51
+ fontSize: "0.8rem",
52
+ color: active ? "rgba(210,235,255,0.95)" : "var(--ck-text-secondary)",
53
+ cursor: "pointer",
54
+ userSelect: "none"
55
+ })
17
56
  };
18
- function ContentLibrary() {
57
+ function ContentLibrary(props) {
58
+ const teamId = String(props?.teamId || "default");
59
+ const [posts, setPosts] = useState([]);
60
+ const [loading, setLoading] = useState(true);
61
+ const [saving, setSaving] = useState(false);
62
+ const [error, setError] = useState(null);
63
+ const [content, setContent] = useState("");
64
+ const [platforms, setPlatforms] = useState(["twitter"]);
65
+ 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);
71
+ try {
72
+ const res = await fetch(`${apiBase}/posts?team=${encodeURIComponent(teamId)}&limit=25&offset=0`);
73
+ const json = await res.json();
74
+ setPosts(Array.isArray(json.data) ? json.data : []);
75
+ } catch (e) {
76
+ setError(e?.message || "Failed to load posts");
77
+ } finally {
78
+ setLoading(false);
79
+ }
80
+ };
81
+ 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
+ });
89
+ };
90
+ const onCreate = async () => {
91
+ setSaving(true);
92
+ setError(null);
93
+ try {
94
+ const res = await fetch(
95
+ `${apiBase}/posts?team=${encodeURIComponent(teamId)}`,
96
+ {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify({
100
+ content,
101
+ platforms,
102
+ status,
103
+ scheduledAt: scheduledAt || void 0
104
+ })
105
+ }
106
+ );
107
+ if (!res.ok) {
108
+ const err = await res.json().catch(() => null);
109
+ throw new Error(err?.message || `Create failed (${res.status})`);
110
+ }
111
+ setContent("");
112
+ setScheduledAt("");
113
+ await refresh();
114
+ } catch (e) {
115
+ setError(e?.message || "Failed to create post");
116
+ } finally {
117
+ setSaving(false);
118
+ }
119
+ };
19
120
  return h(
20
121
  "div",
21
122
  { className: "space-y-3" },
22
123
  h(
23
124
  "div",
24
125
  { style: t.card },
25
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Create New Post"),
26
- h("div", { className: "text-sm", style: t.muted }, "Your content creation tools will live here:"),
126
+ h("div", { className: "text-sm font-medium mb-3", style: t.text }, "New Post"),
27
127
  h(
28
- "ul",
29
- { className: "text-sm mt-2 space-y-1", style: { ...t.muted, listStyle: "disc inside", paddingLeft: "0.5rem" } },
30
- h("li", null, "Rich text editor with media embedding"),
31
- h("li", null, "Multi-platform publishing (Twitter, Instagram, LinkedIn)"),
32
- h("li", null, "Scheduling & auto-posting"),
33
- h("li", null, "Template library for quick starts")
128
+ "div",
129
+ { className: "space-y-2" },
130
+ h("textarea", {
131
+ value: content,
132
+ onChange: (e) => setContent(e.target.value),
133
+ placeholder: "Write your post\u2026",
134
+ rows: 5,
135
+ style: { ...t.input, resize: "vertical", minHeight: "110px" }
136
+ }),
137
+ h(
138
+ "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)
149
+ )
150
+ ),
151
+ h(
152
+ "div",
153
+ { className: "grid grid-cols-1 gap-2 sm:grid-cols-2" },
154
+ h(
155
+ "div",
156
+ null,
157
+ h("div", { className: "text-xs font-medium mb-1", style: t.faint }, "Schedule (optional)"),
158
+ h("input", {
159
+ type: "datetime-local",
160
+ value: scheduledAt,
161
+ onChange: (e) => setScheduledAt(e.target.value),
162
+ style: t.input
163
+ })
164
+ ),
165
+ h(
166
+ "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)
170
+ )
171
+ ),
172
+ h(
173
+ "div",
174
+ { className: "flex flex-wrap gap-2 items-center" },
175
+ h("button", {
176
+ 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
186
+ }, saving ? "Saving\u2026" : "Save draft"),
187
+ h("div", { className: "text-xs", style: t.faint }, "Media embedding + templates next.")
188
+ ),
189
+ error ? h("div", {
190
+ className: "text-sm mt-2",
191
+ style: { color: "rgba(248,113,113,0.95)" }
192
+ }, error) : null
34
193
  )
35
194
  ),
36
195
  h(
37
196
  "div",
38
197
  { style: t.card },
39
- h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Recent Posts"),
40
- h("div", { className: "py-6 text-center text-sm", style: t.faint }, "No posts yet \u2014 create your first post to get started.")
198
+ h("div", { className: "text-sm font-medium mb-2", style: t.text }, "Posts"),
199
+ 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
+ "div",
201
+ { className: "space-y-2" },
202
+ ...posts.map(
203
+ (p) => h(
204
+ "div",
205
+ { key: p.id, style: { ...t.card, padding: "0.75rem" } },
206
+ h(
207
+ "div",
208
+ { 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}` : ""}`)
211
+ ),
212
+ h("div", { className: "mt-2 whitespace-pre-wrap text-sm", style: t.text }, p.content),
213
+ h(
214
+ "div",
215
+ { className: "mt-2 flex flex-wrap gap-2" },
216
+ ...(p.platforms || []).map((pl) => h("span", { key: pl, style: t.pill(true) }, pl))
217
+ )
218
+ )
219
+ )
220
+ )
41
221
  )
42
222
  );
43
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/kitchen-plugin-marketing",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -45,7 +45,7 @@
45
45
  "bundle": "./dist/tabs/accounts.js"
46
46
  }
47
47
  ],
48
- "apiRoutes": "./dist/api/routes.js",
48
+ "apiRoutes": "./dist/api/handler.js",
49
49
  "migrations": "./db/migrations"
50
50
  },
51
51
  "scripts": {