@jiggai/kitchen-plugin-marketing 0.4.1 → 0.5.0

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.
@@ -1002,8 +1002,187 @@ async function handleRequest(req, ctx) {
1002
1002
  return apiError(500, "DATABASE_ERROR", error?.message || "Unknown error");
1003
1003
  }
1004
1004
  }
1005
+ const MEDIA_DIR = (0, import_path2.join)((0, import_os2.homedir)(), ".openclaw", "kitchen", "plugins", "marketing", "media");
1006
+ function ensureMediaDir(team) {
1007
+ const dir = (0, import_path2.join)(MEDIA_DIR, team);
1008
+ (0, import_fs2.mkdirSync)(dir, { recursive: true });
1009
+ return dir;
1010
+ }
1011
+ if (req.path === "/media" && req.method === "POST") {
1012
+ try {
1013
+ const body = req.body;
1014
+ if (!body?.data) return apiError(400, "VALIDATION_ERROR", "data (base64) is required");
1015
+ let base64 = body.data;
1016
+ let detectedMime = body.mimeType || "application/octet-stream";
1017
+ const dataUrlMatch = base64.match(/^data:([^;]+);base64,(.+)$/);
1018
+ if (dataUrlMatch) {
1019
+ detectedMime = dataUrlMatch[1];
1020
+ base64 = dataUrlMatch[2];
1021
+ }
1022
+ const buf = Buffer.from(base64, "base64");
1023
+ const id = (0, import_crypto2.randomUUID)();
1024
+ const ext = (0, import_path2.extname)(body.filename || "") || mimeToExt(detectedMime);
1025
+ const storedFilename = `${id}${ext}`;
1026
+ const dir = ensureMediaDir(teamId);
1027
+ const filePath = (0, import_path2.join)(dir, storedFilename);
1028
+ (0, import_fs2.writeFileSync)(filePath, buf);
1029
+ const { db } = initializeDatabase(teamId);
1030
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1031
+ const userId = getUserId(req);
1032
+ const record = {
1033
+ id,
1034
+ teamId,
1035
+ filename: storedFilename,
1036
+ originalName: body.filename || storedFilename,
1037
+ mimeType: detectedMime,
1038
+ size: buf.length,
1039
+ width: null,
1040
+ height: null,
1041
+ alt: body.alt || null,
1042
+ tags: JSON.stringify(body.tags || []),
1043
+ url: `/api/plugins/marketing/media/${id}/file?team=${encodeURIComponent(teamId)}`,
1044
+ thumbnailUrl: null,
1045
+ createdAt: now,
1046
+ createdBy: userId
1047
+ };
1048
+ await db.insert(media).values(record);
1049
+ return {
1050
+ status: 201,
1051
+ data: {
1052
+ id,
1053
+ filename: record.originalName,
1054
+ mimeType: detectedMime,
1055
+ size: buf.length,
1056
+ url: record.url,
1057
+ alt: record.alt,
1058
+ tags: body.tags || [],
1059
+ createdAt: now
1060
+ }
1061
+ };
1062
+ } catch (error) {
1063
+ return apiError(500, "UPLOAD_ERROR", error?.message || "Upload failed");
1064
+ }
1065
+ }
1066
+ if (req.path === "/media" && req.method === "GET") {
1067
+ try {
1068
+ const { db } = initializeDatabase(teamId);
1069
+ const { limit, offset } = parsePagination(req.query);
1070
+ const conditions = [(0, import_drizzle_orm2.eq)(media.teamId, teamId)];
1071
+ if (req.query.mimeType) {
1072
+ conditions.push((0, import_drizzle_orm2.like)(media.mimeType, `${req.query.mimeType}%`));
1073
+ }
1074
+ const totalResult = await db.select({ count: import_drizzle_orm2.sql`count(*)` }).from(media).where((0, import_drizzle_orm2.and)(...conditions));
1075
+ const total = totalResult[0]?.count ?? 0;
1076
+ const items = await db.select().from(media).where((0, import_drizzle_orm2.and)(...conditions)).orderBy((0, import_drizzle_orm2.desc)(media.createdAt)).limit(limit).offset(offset);
1077
+ const data = items.map((m) => {
1078
+ let thumbnailDataUrl;
1079
+ if (m.mimeType.startsWith("image/")) {
1080
+ const fp = (0, import_path2.join)(MEDIA_DIR, teamId, m.filename);
1081
+ if ((0, import_fs2.existsSync)(fp)) {
1082
+ const raw = (0, import_fs2.readFileSync)(fp);
1083
+ if (raw.length < 2 * 1024 * 1024) {
1084
+ thumbnailDataUrl = `data:${m.mimeType};base64,${raw.toString("base64")}`;
1085
+ }
1086
+ }
1087
+ }
1088
+ return {
1089
+ id: m.id,
1090
+ filename: m.originalName,
1091
+ mimeType: m.mimeType,
1092
+ size: m.size,
1093
+ url: m.url,
1094
+ thumbnailDataUrl,
1095
+ alt: m.alt,
1096
+ tags: JSON.parse(m.tags || "[]"),
1097
+ createdAt: m.createdAt
1098
+ };
1099
+ });
1100
+ return {
1101
+ status: 200,
1102
+ data: { data, total, offset, limit, hasMore: offset + limit < total }
1103
+ };
1104
+ } catch (error) {
1105
+ return apiError(500, "DATABASE_ERROR", error?.message || "Failed to list media");
1106
+ }
1107
+ }
1108
+ const mediaIdMatch = req.path.match(/^\/media\/([a-f0-9-]+)$/);
1109
+ if (mediaIdMatch && req.method === "GET") {
1110
+ try {
1111
+ const { db } = initializeDatabase(teamId);
1112
+ const [item] = await db.select().from(media).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(media.id, mediaIdMatch[1]), (0, import_drizzle_orm2.eq)(media.teamId, teamId)));
1113
+ if (!item) return apiError(404, "NOT_FOUND", "Media not found");
1114
+ let dataUrl;
1115
+ const fp = (0, import_path2.join)(MEDIA_DIR, teamId, item.filename);
1116
+ if ((0, import_fs2.existsSync)(fp)) {
1117
+ const raw = (0, import_fs2.readFileSync)(fp);
1118
+ dataUrl = `data:${item.mimeType};base64,${raw.toString("base64")}`;
1119
+ }
1120
+ return {
1121
+ status: 200,
1122
+ data: {
1123
+ id: item.id,
1124
+ filename: item.originalName,
1125
+ mimeType: item.mimeType,
1126
+ size: item.size,
1127
+ url: item.url,
1128
+ dataUrl,
1129
+ alt: item.alt,
1130
+ tags: JSON.parse(item.tags || "[]"),
1131
+ createdAt: item.createdAt
1132
+ }
1133
+ };
1134
+ } catch (error) {
1135
+ return apiError(500, "DATABASE_ERROR", error?.message || "Failed to get media");
1136
+ }
1137
+ }
1138
+ const mediaFileMatch = req.path.match(/^\/media\/([a-f0-9-]+)\/file$/);
1139
+ if (mediaFileMatch && req.method === "GET") {
1140
+ try {
1141
+ const { db } = initializeDatabase(teamId);
1142
+ const [item] = await db.select().from(media).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(media.id, mediaFileMatch[1]), (0, import_drizzle_orm2.eq)(media.teamId, teamId)));
1143
+ if (!item) return apiError(404, "NOT_FOUND", "Media not found");
1144
+ const fp = (0, import_path2.join)(MEDIA_DIR, teamId, item.filename);
1145
+ if (!(0, import_fs2.existsSync)(fp)) return apiError(404, "NOT_FOUND", "File missing from disk");
1146
+ const raw = (0, import_fs2.readFileSync)(fp);
1147
+ const dataUrl = `data:${item.mimeType};base64,${raw.toString("base64")}`;
1148
+ return { status: 200, data: { dataUrl, mimeType: item.mimeType, filename: item.originalName } };
1149
+ } catch (error) {
1150
+ return apiError(500, "FILE_ERROR", error?.message || "Failed to serve file");
1151
+ }
1152
+ }
1153
+ if (mediaIdMatch && req.method === "DELETE") {
1154
+ try {
1155
+ const { db } = initializeDatabase(teamId);
1156
+ const [item] = await db.select().from(media).where((0, import_drizzle_orm2.and)((0, import_drizzle_orm2.eq)(media.id, mediaIdMatch[1]), (0, import_drizzle_orm2.eq)(media.teamId, teamId)));
1157
+ if (!item) return apiError(404, "NOT_FOUND", "Media not found");
1158
+ const fp = (0, import_path2.join)(MEDIA_DIR, teamId, item.filename);
1159
+ try {
1160
+ (0, import_fs2.unlinkSync)(fp);
1161
+ } catch {
1162
+ }
1163
+ await db.delete(media).where((0, import_drizzle_orm2.eq)(media.id, mediaIdMatch[1]));
1164
+ return { status: 200, data: { deleted: true, id: mediaIdMatch[1] } };
1165
+ } catch (error) {
1166
+ return apiError(500, "DATABASE_ERROR", error?.message || "Failed to delete media");
1167
+ }
1168
+ }
1005
1169
  return apiError(501, "NOT_IMPLEMENTED", `No handler for ${req.method} ${req.path}`);
1006
1170
  }
1171
+ function mimeToExt(mime) {
1172
+ const map = {
1173
+ "image/jpeg": ".jpg",
1174
+ "image/png": ".png",
1175
+ "image/gif": ".gif",
1176
+ "image/webp": ".webp",
1177
+ "image/svg+xml": ".svg",
1178
+ "video/mp4": ".mp4",
1179
+ "video/webm": ".webm",
1180
+ "video/quicktime": ".mov",
1181
+ "audio/mpeg": ".mp3",
1182
+ "audio/wav": ".wav"
1183
+ };
1184
+ return map[mime] || "";
1185
+ }
1007
1186
  // Annotate the CommonJS export names for ESM import in node:
1008
1187
  0 && (module.exports = {
1009
1188
  handleRequest
@@ -120,6 +120,11 @@
120
120
  const [scheduledAt, setScheduledAt] = useState("");
121
121
  const [mediaUrl, setMediaUrl] = useState("");
122
122
  const [showMedia, setShowMedia] = useState(false);
123
+ const [mediaLibrary, setMediaLibrary] = useState([]);
124
+ const [showMediaPicker, setShowMediaPicker] = useState(false);
125
+ const [uploading, setUploading] = useState(false);
126
+ const [selectedMediaIds, setSelectedMediaIds] = useState([]);
127
+ const fileInputRef = useRef(null);
123
128
  const successTimeout = useRef(null);
124
129
  const postizHeaders = useMemo(() => {
125
130
  try {
@@ -154,10 +159,64 @@
154
159
  } catch {
155
160
  }
156
161
  }, [apiBase, teamId]);
162
+ const loadMedia = useCallback(async () => {
163
+ try {
164
+ const res = await fetch(`${apiBase}/media?team=${encodeURIComponent(teamId)}&limit=100`);
165
+ const json = await res.json();
166
+ setMediaLibrary(Array.isArray(json.data) ? json.data : []);
167
+ } catch {
168
+ }
169
+ }, [apiBase, teamId]);
170
+ const handleFileUpload = useCallback(async (files) => {
171
+ if (!files || files.length === 0) return;
172
+ setUploading(true);
173
+ setError(null);
174
+ try {
175
+ for (const file of Array.from(files)) {
176
+ const base64 = await new Promise((resolve, reject) => {
177
+ const reader = new FileReader();
178
+ reader.onload = () => resolve(reader.result);
179
+ reader.onerror = reject;
180
+ reader.readAsDataURL(file);
181
+ });
182
+ const res = await fetch(`${apiBase}/media?team=${encodeURIComponent(teamId)}`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json" },
185
+ body: JSON.stringify({ data: base64, filename: file.name, mimeType: file.type })
186
+ });
187
+ if (!res.ok) {
188
+ const err = await res.json().catch(() => ({}));
189
+ throw new Error(err.message || `Upload failed (${res.status})`);
190
+ }
191
+ const item = await res.json();
192
+ setSelectedMediaIds((prev) => [...prev, item.id]);
193
+ }
194
+ await loadMedia();
195
+ showSuccess(`Uploaded ${files.length} file${files.length > 1 ? "s" : ""}`);
196
+ } catch (e) {
197
+ setError(e?.message || "Upload failed");
198
+ } finally {
199
+ setUploading(false);
200
+ if (fileInputRef.current) fileInputRef.current.value = "";
201
+ }
202
+ }, [apiBase, teamId, loadMedia]);
203
+ const deleteMedia = useCallback(async (id) => {
204
+ try {
205
+ await fetch(`${apiBase}/media/${id}?team=${encodeURIComponent(teamId)}`, { method: "DELETE" });
206
+ setSelectedMediaIds((prev) => prev.filter((x) => x !== id));
207
+ await loadMedia();
208
+ } catch {
209
+ }
210
+ }, [apiBase, teamId, loadMedia]);
211
+ const toggleMediaSelect = (id) => {
212
+ setSelectedMediaIds(
213
+ (prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
214
+ );
215
+ };
157
216
  useEffect(() => {
158
217
  setLoading(true);
159
- Promise.all([loadDrivers(), loadPosts()]).finally(() => setLoading(false));
160
- }, [loadDrivers, loadPosts]);
218
+ Promise.all([loadDrivers(), loadPosts(), loadMedia()]).finally(() => setLoading(false));
219
+ }, [loadDrivers, loadPosts, loadMedia]);
161
220
  const connectedDrivers = useMemo(() => drivers.filter((d) => d.connected), [drivers]);
162
221
  const disconnectedDrivers = useMemo(() => drivers.filter((d) => !d.connected), [drivers]);
163
222
  const togglePlatform = (platform) => {
@@ -348,7 +407,7 @@
348
407
  )
349
408
  )
350
409
  ),
351
- // Media URL (collapsible)
410
+ // Media (upload, URL, or library picker)
352
411
  h(
353
412
  "div",
354
413
  null,
@@ -359,14 +418,228 @@
359
418
  }, showMedia ? "\u2212 Media" : "+ Media"),
360
419
  showMedia && h(
361
420
  "div",
362
- { className: "mt-2" },
363
- h("input", {
364
- type: "url",
365
- value: mediaUrl,
366
- onChange: (e) => setMediaUrl(e.target.value),
367
- placeholder: "Paste image or video URL\u2026",
368
- style: t.input
369
- })
421
+ { className: "mt-2 space-y-2" },
422
+ // Upload + URL row
423
+ h(
424
+ "div",
425
+ { className: "flex gap-2 items-center" },
426
+ h("input", {
427
+ ref: fileInputRef,
428
+ type: "file",
429
+ accept: "image/*,video/*",
430
+ multiple: true,
431
+ style: { display: "none" },
432
+ onChange: (e) => handleFileUpload(e.target.files)
433
+ }),
434
+ h("button", {
435
+ type: "button",
436
+ onClick: () => fileInputRef.current?.click(),
437
+ style: { ...t.btnGhost, padding: "0.35rem 0.7rem", fontSize: "0.8rem", whiteSpace: "nowrap" },
438
+ disabled: uploading
439
+ }, uploading ? "\u23F3 Uploading\u2026" : "\u{1F4C1} Upload"),
440
+ h("button", {
441
+ type: "button",
442
+ onClick: () => {
443
+ loadMedia();
444
+ setShowMediaPicker(!showMediaPicker);
445
+ },
446
+ style: { ...t.btnGhost, padding: "0.35rem 0.7rem", fontSize: "0.8rem", whiteSpace: "nowrap" }
447
+ }, showMediaPicker ? "Hide Library" : "\u{1F5BC}\uFE0F Library"),
448
+ h("input", {
449
+ type: "url",
450
+ value: mediaUrl,
451
+ onChange: (e) => setMediaUrl(e.target.value),
452
+ placeholder: "\u2026or paste a URL",
453
+ style: { ...t.input, flex: 1 }
454
+ })
455
+ ),
456
+ // Selected media thumbnails
457
+ selectedMediaIds.length > 0 && h(
458
+ "div",
459
+ { className: "flex flex-wrap gap-2" },
460
+ ...selectedMediaIds.map((id) => {
461
+ const item = mediaLibrary.find((m) => m.id === id);
462
+ if (!item) return null;
463
+ return h(
464
+ "div",
465
+ {
466
+ key: id,
467
+ style: {
468
+ position: "relative",
469
+ width: "72px",
470
+ height: "72px",
471
+ borderRadius: "8px",
472
+ overflow: "hidden",
473
+ border: "2px solid rgba(127,90,240,0.5)"
474
+ }
475
+ },
476
+ item.mimeType?.startsWith("video/") ? h("div", {
477
+ style: {
478
+ width: "100%",
479
+ height: "100%",
480
+ background: "rgba(0,0,0,0.4)",
481
+ display: "flex",
482
+ alignItems: "center",
483
+ justifyContent: "center",
484
+ color: "white",
485
+ fontSize: "1.2rem"
486
+ }
487
+ }, "\u{1F3A5}") : h("img", {
488
+ src: item.thumbnailDataUrl || item.url,
489
+ style: { width: "100%", height: "100%", objectFit: "cover" }
490
+ }),
491
+ h("button", {
492
+ type: "button",
493
+ onClick: () => toggleMediaSelect(id),
494
+ style: {
495
+ position: "absolute",
496
+ top: "2px",
497
+ right: "2px",
498
+ background: "rgba(0,0,0,0.6)",
499
+ border: "none",
500
+ borderRadius: "50%",
501
+ width: "18px",
502
+ height: "18px",
503
+ color: "white",
504
+ fontSize: "0.65rem",
505
+ cursor: "pointer",
506
+ display: "flex",
507
+ alignItems: "center",
508
+ justifyContent: "center",
509
+ lineHeight: "1"
510
+ }
511
+ }, "\u2715")
512
+ );
513
+ })
514
+ ),
515
+ // Media library picker grid
516
+ showMediaPicker && h(
517
+ "div",
518
+ {
519
+ style: {
520
+ background: "rgba(255,255,255,0.02)",
521
+ border: "1px solid var(--ck-border-subtle)",
522
+ borderRadius: "10px",
523
+ padding: "0.75rem",
524
+ maxHeight: "260px",
525
+ overflowY: "auto"
526
+ }
527
+ },
528
+ h(
529
+ "div",
530
+ { className: "text-xs font-medium mb-2", style: t.faint },
531
+ `Media Library (${mediaLibrary.length} items)`
532
+ ),
533
+ mediaLibrary.length === 0 ? h("div", { className: "text-xs py-4 text-center", style: t.faint }, "No media yet. Upload some files!") : h(
534
+ "div",
535
+ {
536
+ style: {
537
+ display: "grid",
538
+ gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))",
539
+ gap: "0.5rem"
540
+ }
541
+ },
542
+ ...mediaLibrary.map((item) => {
543
+ const isSelected = selectedMediaIds.includes(item.id);
544
+ return h(
545
+ "div",
546
+ {
547
+ key: item.id,
548
+ onClick: () => toggleMediaSelect(item.id),
549
+ style: {
550
+ position: "relative",
551
+ cursor: "pointer",
552
+ width: "100%",
553
+ paddingTop: "100%",
554
+ borderRadius: "8px",
555
+ overflow: "hidden",
556
+ border: isSelected ? "2px solid rgba(127,90,240,0.7)" : "1px solid var(--ck-border-subtle)",
557
+ boxShadow: isSelected ? "0 0 8px rgba(127,90,240,0.3)" : "none"
558
+ }
559
+ },
560
+ item.mimeType?.startsWith("video/") ? h("div", {
561
+ style: {
562
+ position: "absolute",
563
+ inset: "0",
564
+ background: "rgba(0,0,0,0.4)",
565
+ display: "flex",
566
+ alignItems: "center",
567
+ justifyContent: "center",
568
+ color: "white",
569
+ fontSize: "1.5rem"
570
+ }
571
+ }, "\u{1F3A5}") : h("img", {
572
+ src: item.thumbnailDataUrl || item.url,
573
+ style: {
574
+ position: "absolute",
575
+ inset: "0",
576
+ width: "100%",
577
+ height: "100%",
578
+ objectFit: "cover"
579
+ }
580
+ }),
581
+ isSelected && h("div", {
582
+ style: {
583
+ position: "absolute",
584
+ top: "4px",
585
+ right: "4px",
586
+ background: "rgba(127,90,240,0.85)",
587
+ borderRadius: "50%",
588
+ width: "20px",
589
+ height: "20px",
590
+ display: "flex",
591
+ alignItems: "center",
592
+ justifyContent: "center",
593
+ color: "white",
594
+ fontSize: "0.7rem",
595
+ fontWeight: "700"
596
+ }
597
+ }, "\u2713"),
598
+ h("button", {
599
+ type: "button",
600
+ onClick: (e) => {
601
+ e.stopPropagation();
602
+ deleteMedia(item.id);
603
+ },
604
+ style: {
605
+ position: "absolute",
606
+ bottom: "4px",
607
+ right: "4px",
608
+ background: "rgba(220,38,38,0.7)",
609
+ border: "none",
610
+ borderRadius: "50%",
611
+ width: "18px",
612
+ height: "18px",
613
+ color: "white",
614
+ fontSize: "0.6rem",
615
+ cursor: "pointer",
616
+ display: "flex",
617
+ alignItems: "center",
618
+ justifyContent: "center",
619
+ opacity: "0.6",
620
+ lineHeight: "1"
621
+ },
622
+ title: "Delete from library"
623
+ }, "\u{1F5D1}"),
624
+ h("div", {
625
+ style: {
626
+ position: "absolute",
627
+ bottom: "0",
628
+ left: "0",
629
+ right: "0",
630
+ background: "rgba(0,0,0,0.6)",
631
+ padding: "2px 4px",
632
+ fontSize: "0.55rem",
633
+ color: "rgba(255,255,255,0.8)",
634
+ whiteSpace: "nowrap",
635
+ overflow: "hidden",
636
+ textOverflow: "ellipsis"
637
+ }
638
+ }, item.filename)
639
+ );
640
+ })
641
+ )
642
+ )
370
643
  )
371
644
  ),
372
645
  // Schedule (only if any selected platform supports it)
@@ -475,11 +748,35 @@
475
748
  textAlign: "center"
476
749
  }
477
750
  }, "Start writing to see a preview"),
478
- // Media preview
479
- mediaUrl && showMedia && h(
751
+ // Media preview (selected library items + URL)
752
+ (selectedMediaIds.length > 0 || mediaUrl && showMedia) && h(
480
753
  "div",
481
- { className: "mt-3" },
482
- h("img", {
754
+ { className: "mt-3 space-y-2" },
755
+ ...selectedMediaIds.map((id) => {
756
+ const item = mediaLibrary.find((m) => m.id === id);
757
+ if (!item) return null;
758
+ return item.mimeType?.startsWith("video/") ? h("div", {
759
+ key: id,
760
+ style: {
761
+ background: "rgba(0,0,0,0.3)",
762
+ borderRadius: "8px",
763
+ border: "1px solid var(--ck-border-subtle)",
764
+ padding: "1rem",
765
+ textAlign: "center",
766
+ color: "var(--ck-text-secondary)",
767
+ fontSize: "0.8rem"
768
+ }
769
+ }, `\u{1F3A5} ${item.filename}`) : h("img", {
770
+ key: id,
771
+ src: item.thumbnailDataUrl || item.url,
772
+ style: {
773
+ maxWidth: "100%",
774
+ borderRadius: "8px",
775
+ border: "1px solid var(--ck-border-subtle)"
776
+ }
777
+ });
778
+ }),
779
+ mediaUrl && showMedia && h("img", {
483
780
  src: mediaUrl,
484
781
  style: {
485
782
  maxWidth: "100%",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/kitchen-plugin-marketing",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Marketing Suite plugin for ClawKitchen",
5
5
  "main": "dist/index.js",
6
6
  "files": [