@hasna/microservices 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/microservices/microservice-domains/src/lib/brandsight.ts +138 -73
- package/microservices/microservice-domains/src/lib/godaddy.ts +149 -139
- package/microservices/microservice-domains/src/lib/namecheap.ts +63 -275
- package/microservices/microservice-social/package.json +2 -1
- package/microservices/microservice-social/src/cli/index.ts +906 -12
- package/microservices/microservice-social/src/db/migrations.ts +72 -0
- package/microservices/microservice-social/src/db/social.ts +33 -3
- package/microservices/microservice-social/src/lib/audience.ts +353 -0
- package/microservices/microservice-social/src/lib/content-ai.ts +278 -0
- package/microservices/microservice-social/src/lib/media.ts +311 -0
- package/microservices/microservice-social/src/lib/mentions.ts +434 -0
- package/microservices/microservice-social/src/lib/metrics-sync.ts +264 -0
- package/microservices/microservice-social/src/lib/publisher.ts +377 -0
- package/microservices/microservice-social/src/lib/scheduler.ts +229 -0
- package/microservices/microservice-social/src/lib/sentiment.ts +256 -0
- package/microservices/microservice-social/src/lib/threads.ts +291 -0
- package/microservices/microservice-social/src/mcp/index.ts +776 -6
- package/microservices/microservice-social/src/server/index.ts +441 -0
- package/package.json +1 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* REST API server + web dashboard for microservice-social.
|
|
5
|
+
* Serves a single-page HTML dashboard and JSON API endpoints.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
listPosts,
|
|
10
|
+
getPost,
|
|
11
|
+
createPost,
|
|
12
|
+
updatePost,
|
|
13
|
+
deletePost,
|
|
14
|
+
publishPost,
|
|
15
|
+
listAccounts,
|
|
16
|
+
getCalendar,
|
|
17
|
+
getOverallStats,
|
|
18
|
+
getEngagementStats,
|
|
19
|
+
type PostStatus,
|
|
20
|
+
} from "../db/social.js";
|
|
21
|
+
import { listMentions } from "../lib/mentions.js";
|
|
22
|
+
|
|
23
|
+
const PORT = parseInt(process.env["PORT"] ?? "19650");
|
|
24
|
+
|
|
25
|
+
function json(data: unknown, status = 200): Response {
|
|
26
|
+
return new Response(JSON.stringify(data, null, 2), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cors(): Response {
|
|
33
|
+
return new Response(null, {
|
|
34
|
+
status: 204,
|
|
35
|
+
headers: {
|
|
36
|
+
"Access-Control-Allow-Origin": "*",
|
|
37
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
38
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const server = Bun.serve({
|
|
44
|
+
port: PORT,
|
|
45
|
+
async fetch(req) {
|
|
46
|
+
const url = new URL(req.url);
|
|
47
|
+
const path = url.pathname;
|
|
48
|
+
|
|
49
|
+
if (req.method === "OPTIONS") return cors();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// --- API Routes ---
|
|
53
|
+
|
|
54
|
+
// POST /api/posts/:id/publish — must come before generic /api/posts/:id
|
|
55
|
+
if (path.match(/^\/api\/posts\/[^/]+\/publish$/) && req.method === "POST") {
|
|
56
|
+
const id = path.split("/")[3];
|
|
57
|
+
const post = publishPost(id);
|
|
58
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
59
|
+
return json(post);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// GET /api/posts — list posts
|
|
63
|
+
if (path === "/api/posts" && req.method === "GET") {
|
|
64
|
+
const status = url.searchParams.get("status") ?? undefined;
|
|
65
|
+
const search = url.searchParams.get("search") ?? undefined;
|
|
66
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50");
|
|
67
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
|
68
|
+
const posts = listPosts({ status: status as PostStatus | undefined, search, limit, offset });
|
|
69
|
+
return json(posts);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// POST /api/posts — create post
|
|
73
|
+
if (path === "/api/posts" && req.method === "POST") {
|
|
74
|
+
const body = await req.json();
|
|
75
|
+
if (!body.account_id || !body.content) {
|
|
76
|
+
return json({ error: "account_id and content are required" }, 422);
|
|
77
|
+
}
|
|
78
|
+
const post = createPost({
|
|
79
|
+
account_id: body.account_id,
|
|
80
|
+
content: body.content,
|
|
81
|
+
media_urls: body.media_urls,
|
|
82
|
+
status: body.status,
|
|
83
|
+
scheduled_at: body.scheduled_at,
|
|
84
|
+
tags: body.tags,
|
|
85
|
+
recurrence: body.recurrence,
|
|
86
|
+
});
|
|
87
|
+
return json(post, 201);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// GET /api/posts/:id — single post
|
|
91
|
+
if (path.match(/^\/api\/posts\/[^/]+$/) && req.method === "GET") {
|
|
92
|
+
const id = path.split("/")[3];
|
|
93
|
+
const post = getPost(id);
|
|
94
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
95
|
+
return json(post);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// PUT /api/posts/:id — update post
|
|
99
|
+
if (path.match(/^\/api\/posts\/[^/]+$/) && req.method === "PUT") {
|
|
100
|
+
const id = path.split("/")[3];
|
|
101
|
+
const body = await req.json();
|
|
102
|
+
const post = updatePost(id, body);
|
|
103
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
104
|
+
return json(post);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// DELETE /api/posts/:id — delete post
|
|
108
|
+
if (path.match(/^\/api\/posts\/[^/]+$/) && req.method === "DELETE") {
|
|
109
|
+
const id = path.split("/")[3];
|
|
110
|
+
const deleted = deletePost(id);
|
|
111
|
+
if (!deleted) return json({ error: "Not found" }, 404);
|
|
112
|
+
return json({ id, deleted: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// GET /api/accounts — list accounts
|
|
116
|
+
if (path === "/api/accounts" && req.method === "GET") {
|
|
117
|
+
const accounts = listAccounts();
|
|
118
|
+
return json(accounts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// GET /api/calendar — calendar view
|
|
122
|
+
if (path === "/api/calendar" && req.method === "GET") {
|
|
123
|
+
const from = url.searchParams.get("from") ?? undefined;
|
|
124
|
+
const to = url.searchParams.get("to") ?? undefined;
|
|
125
|
+
return json(getCalendar(from, to));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// GET /api/analytics — overall stats
|
|
129
|
+
if (path === "/api/analytics" && req.method === "GET") {
|
|
130
|
+
return json(getOverallStats());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// GET /api/mentions — mentions list
|
|
134
|
+
if (path === "/api/mentions" && req.method === "GET") {
|
|
135
|
+
const account_id = url.searchParams.get("account_id") ?? undefined;
|
|
136
|
+
const unreadParam = url.searchParams.get("unread");
|
|
137
|
+
const unread = unreadParam === "true" ? true : unreadParam === "false" ? false : undefined;
|
|
138
|
+
const mentions = listMentions(account_id, { unread });
|
|
139
|
+
return json(mentions);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// GET /api/stats — engagement stats
|
|
143
|
+
if (path === "/api/stats" && req.method === "GET") {
|
|
144
|
+
return json(getEngagementStats());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Dashboard (SPA) ---
|
|
148
|
+
return new Response(DASHBOARD_HTML, {
|
|
149
|
+
headers: { "Content-Type": "text/html" },
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
console.log(`Social dashboard running at http://localhost:${PORT}`);
|
|
158
|
+
|
|
159
|
+
const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
160
|
+
<html lang="en">
|
|
161
|
+
<head>
|
|
162
|
+
<meta charset="UTF-8">
|
|
163
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
164
|
+
<title>Social Dashboard</title>
|
|
165
|
+
<style>
|
|
166
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
167
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; }
|
|
168
|
+
.container { max-width: 1100px; margin: 0 auto; padding: 20px; }
|
|
169
|
+
h1 { font-size: 1.5rem; margin-bottom: 20px; color: #fff; }
|
|
170
|
+
h2 { font-size: 1.1rem; margin-bottom: 12px; color: #ccc; }
|
|
171
|
+
|
|
172
|
+
/* Tabs */
|
|
173
|
+
.tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 8px; }
|
|
174
|
+
.tab { padding: 8px 16px; background: none; border: none; color: #888; cursor: pointer; font-size: 0.9rem; border-radius: 6px 6px 0 0; }
|
|
175
|
+
.tab:hover { color: #fff; background: #1a1a1a; }
|
|
176
|
+
.tab.active { color: #10b981; background: #1a1a1a; border-bottom: 2px solid #10b981; }
|
|
177
|
+
|
|
178
|
+
/* Stats cards */
|
|
179
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
180
|
+
.stat { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 14px 16px; }
|
|
181
|
+
.stat-value { font-size: 1.6rem; font-weight: bold; color: #10b981; }
|
|
182
|
+
.stat-label { font-size: 0.75rem; color: #888; margin-top: 2px; }
|
|
183
|
+
|
|
184
|
+
/* Search */
|
|
185
|
+
.search { margin-bottom: 20px; }
|
|
186
|
+
.search input { width: 100%; padding: 10px 14px; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; color: #fff; font-size: 0.9rem; }
|
|
187
|
+
.search input:focus { outline: none; border-color: #10b981; }
|
|
188
|
+
|
|
189
|
+
/* List */
|
|
190
|
+
.list { display: flex; flex-direction: column; gap: 8px; }
|
|
191
|
+
.item { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 14px 16px; cursor: pointer; transition: border-color 0.2s; }
|
|
192
|
+
.item:hover { border-color: #10b981; }
|
|
193
|
+
.item-title { font-weight: 600; color: #fff; margin-bottom: 4px; }
|
|
194
|
+
.item-meta { font-size: 0.8rem; color: #888; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
195
|
+
|
|
196
|
+
/* Badges */
|
|
197
|
+
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
|
198
|
+
.badge-draft { background: #6b728020; color: #9ca3af; }
|
|
199
|
+
.badge-scheduled { background: #3b82f620; color: #60a5fa; }
|
|
200
|
+
.badge-published { background: #10b98120; color: #10b981; }
|
|
201
|
+
.badge-failed { background: #ef444420; color: #ef4444; }
|
|
202
|
+
.badge-pending_review { background: #f59e0b20; color: #f59e0b; }
|
|
203
|
+
|
|
204
|
+
/* Detail view */
|
|
205
|
+
.detail { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 20px; margin-top: 8px; }
|
|
206
|
+
.detail pre { white-space: pre-wrap; font-size: 0.85rem; line-height: 1.6; max-height: 400px; overflow-y: auto; margin-top: 12px; padding: 12px; background: #111; border-radius: 6px; }
|
|
207
|
+
.back { color: #10b981; cursor: pointer; margin-bottom: 12px; display: inline-block; }
|
|
208
|
+
|
|
209
|
+
/* Tags */
|
|
210
|
+
.tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
|
211
|
+
.tag { background: #333; padding: 2px 6px; border-radius: 3px; font-size: 0.7rem; }
|
|
212
|
+
|
|
213
|
+
/* Calendar */
|
|
214
|
+
.cal-date { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 14px 16px; margin-bottom: 8px; }
|
|
215
|
+
.cal-date-header { font-weight: 600; color: #60a5fa; margin-bottom: 8px; font-size: 0.9rem; }
|
|
216
|
+
.cal-post { font-size: 0.85rem; padding: 6px 0; border-top: 1px solid #222; color: #ccc; }
|
|
217
|
+
.cal-post:first-child { border-top: none; }
|
|
218
|
+
.cal-time { color: #888; font-size: 0.75rem; margin-right: 8px; }
|
|
219
|
+
|
|
220
|
+
/* Mentions */
|
|
221
|
+
.mention { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 12px 16px; margin-bottom: 8px; }
|
|
222
|
+
.mention.unread { border-left: 3px solid #f59e0b; }
|
|
223
|
+
.mention-author { font-weight: 600; color: #fff; font-size: 0.9rem; }
|
|
224
|
+
.mention-content { font-size: 0.85rem; color: #ccc; margin-top: 4px; }
|
|
225
|
+
.mention-meta { font-size: 0.75rem; color: #888; margin-top: 4px; }
|
|
226
|
+
|
|
227
|
+
/* Accounts */
|
|
228
|
+
.account-card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 14px 16px; }
|
|
229
|
+
.account-handle { font-weight: 600; color: #fff; }
|
|
230
|
+
.account-platform { font-size: 0.8rem; color: #60a5fa; text-transform: uppercase; }
|
|
231
|
+
.connected { color: #10b981; }
|
|
232
|
+
.disconnected { color: #ef4444; }
|
|
233
|
+
|
|
234
|
+
.empty { text-align: center; color: #666; padding: 40px; }
|
|
235
|
+
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; }
|
|
236
|
+
</style>
|
|
237
|
+
</head>
|
|
238
|
+
<body>
|
|
239
|
+
<div class="container">
|
|
240
|
+
<h1>Social Dashboard</h1>
|
|
241
|
+
<div class="tabs">
|
|
242
|
+
<button class="tab active" data-tab="overview" onclick="switchTab('overview')">Overview</button>
|
|
243
|
+
<button class="tab" data-tab="posts" onclick="switchTab('posts')">Posts</button>
|
|
244
|
+
<button class="tab" data-tab="calendar" onclick="switchTab('calendar')">Calendar</button>
|
|
245
|
+
<button class="tab" data-tab="mentions" onclick="switchTab('mentions')">Mentions</button>
|
|
246
|
+
<button class="tab" data-tab="accounts" onclick="switchTab('accounts')">Accounts</button>
|
|
247
|
+
</div>
|
|
248
|
+
<div id="app"></div>
|
|
249
|
+
</div>
|
|
250
|
+
<script>
|
|
251
|
+
const API = '';
|
|
252
|
+
const app = document.getElementById('app');
|
|
253
|
+
let currentTab = 'overview';
|
|
254
|
+
|
|
255
|
+
async function api(path) {
|
|
256
|
+
const res = await fetch(API + path);
|
|
257
|
+
return res.json();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function esc(s) {
|
|
261
|
+
if (!s) return '';
|
|
262
|
+
const d = document.createElement('div');
|
|
263
|
+
d.textContent = s;
|
|
264
|
+
return d.innerHTML;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function badgeClass(status) {
|
|
268
|
+
return 'badge badge-' + (status || 'draft');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function switchTab(tab) {
|
|
272
|
+
currentTab = tab;
|
|
273
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
|
274
|
+
render();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function render() {
|
|
278
|
+
switch (currentTab) {
|
|
279
|
+
case 'overview': renderOverview(); break;
|
|
280
|
+
case 'posts': renderPosts(); break;
|
|
281
|
+
case 'calendar': renderCalendar(); break;
|
|
282
|
+
case 'mentions': renderMentions(); break;
|
|
283
|
+
case 'accounts': renderAccounts(); break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function renderOverview() {
|
|
288
|
+
const [analytics, stats, mentions] = await Promise.all([
|
|
289
|
+
api('/api/analytics'),
|
|
290
|
+
api('/api/stats'),
|
|
291
|
+
api('/api/mentions?unread=true'),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
const engRate = stats.total_posts > 0
|
|
295
|
+
? ((stats.total_likes + stats.total_shares + stats.total_comments) / Math.max(stats.total_impressions, 1) * 100).toFixed(1)
|
|
296
|
+
: '0.0';
|
|
297
|
+
|
|
298
|
+
app.innerHTML = \`
|
|
299
|
+
<div class="stats">
|
|
300
|
+
<div class="stat"><div class="stat-value">\${analytics.total_posts}</div><div class="stat-label">Total Posts</div></div>
|
|
301
|
+
<div class="stat"><div class="stat-value">\${analytics.posts_by_status?.scheduled ?? 0}</div><div class="stat-label">Scheduled</div></div>
|
|
302
|
+
<div class="stat"><div class="stat-value">\${analytics.posts_by_status?.published ?? 0}</div><div class="stat-label">Published</div></div>
|
|
303
|
+
<div class="stat"><div class="stat-value">\${engRate}%</div><div class="stat-label">Engagement Rate</div></div>
|
|
304
|
+
<div class="stat"><div class="stat-value">\${analytics.total_accounts}</div><div class="stat-label">Accounts</div></div>
|
|
305
|
+
<div class="stat"><div class="stat-value">\${mentions.length}</div><div class="stat-label">Unread Mentions</div></div>
|
|
306
|
+
</div>
|
|
307
|
+
<h2>Engagement</h2>
|
|
308
|
+
<div class="stats" style="margin-bottom:20px">
|
|
309
|
+
<div class="stat"><div class="stat-value">\${analytics.engagement.total_likes}</div><div class="stat-label">Likes</div></div>
|
|
310
|
+
<div class="stat"><div class="stat-value">\${analytics.engagement.total_shares}</div><div class="stat-label">Shares</div></div>
|
|
311
|
+
<div class="stat"><div class="stat-value">\${analytics.engagement.total_comments}</div><div class="stat-label">Comments</div></div>
|
|
312
|
+
<div class="stat"><div class="stat-value">\${analytics.engagement.total_impressions}</div><div class="stat-label">Impressions</div></div>
|
|
313
|
+
<div class="stat"><div class="stat-value">\${analytics.engagement.total_clicks}</div><div class="stat-label">Clicks</div></div>
|
|
314
|
+
</div>
|
|
315
|
+
\`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function renderPosts(query) {
|
|
319
|
+
const qs = query ? '?search=' + encodeURIComponent(query) + '&limit=50' : '?limit=50';
|
|
320
|
+
const posts = await api('/api/posts' + qs);
|
|
321
|
+
|
|
322
|
+
app.innerHTML = \`
|
|
323
|
+
<div class="search"><input type="text" placeholder="Search posts..." value="\${esc(query || '')}" id="search"></div>
|
|
324
|
+
<div class="list">\${posts.length === 0 ? '<div class="empty">No posts found.</div>' : posts.map(p => \`
|
|
325
|
+
<div class="item" onclick="renderPostDetail('\${p.id}')">
|
|
326
|
+
<div class="item-title">\${esc(p.content.substring(0, 100))}\${p.content.length > 100 ? '...' : ''}</div>
|
|
327
|
+
<div class="item-meta">
|
|
328
|
+
<span class="\${badgeClass(p.status)}">\${p.status}</span>
|
|
329
|
+
\${p.scheduled_at ? '<span>Scheduled: ' + esc(p.scheduled_at) + '</span>' : ''}
|
|
330
|
+
\${p.published_at ? '<span>Published: ' + esc(p.published_at) + '</span>' : ''}
|
|
331
|
+
\${Object.keys(p.engagement || {}).length ? '<span>Likes: ' + (p.engagement.likes || 0) + ' Shares: ' + (p.engagement.shares || 0) + '</span>' : ''}
|
|
332
|
+
</div>
|
|
333
|
+
\${p.tags && p.tags.length ? '<div class="tags">' + p.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</div>' : ''}
|
|
334
|
+
</div>
|
|
335
|
+
\`).join('')}</div>
|
|
336
|
+
\`;
|
|
337
|
+
|
|
338
|
+
document.getElementById('search').addEventListener('input', e => {
|
|
339
|
+
clearTimeout(window._st);
|
|
340
|
+
window._st = setTimeout(() => renderPosts(e.target.value), 300);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function renderPostDetail(id) {
|
|
345
|
+
const p = await api('/api/posts/' + id);
|
|
346
|
+
if (p.error) { renderPosts(); return; }
|
|
347
|
+
|
|
348
|
+
app.innerHTML = \`
|
|
349
|
+
<span class="back" onclick="switchTab('posts')">← Back to Posts</span>
|
|
350
|
+
<div class="detail">
|
|
351
|
+
<div class="item-meta" style="margin-bottom:12px">
|
|
352
|
+
<span class="\${badgeClass(p.status)}">\${p.status}</span>
|
|
353
|
+
<span>Account: \${esc(p.account_id)}</span>
|
|
354
|
+
\${p.scheduled_at ? '<span>Scheduled: ' + esc(p.scheduled_at) + '</span>' : ''}
|
|
355
|
+
\${p.published_at ? '<span>Published: ' + esc(p.published_at) + '</span>' : ''}
|
|
356
|
+
</div>
|
|
357
|
+
\${p.tags && p.tags.length ? '<div class="tags" style="margin-bottom:12px">' + p.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('') + '</div>' : ''}
|
|
358
|
+
<pre>\${esc(p.content)}</pre>
|
|
359
|
+
\${Object.keys(p.engagement || {}).length ? '<div style="margin-top:12px"><h2>Engagement</h2><div class="stats"><div class="stat"><div class="stat-value">' + (p.engagement.likes||0) + '</div><div class="stat-label">Likes</div></div><div class="stat"><div class="stat-value">' + (p.engagement.shares||0) + '</div><div class="stat-label">Shares</div></div><div class="stat"><div class="stat-value">' + (p.engagement.comments||0) + '</div><div class="stat-label">Comments</div></div><div class="stat"><div class="stat-value">' + (p.engagement.impressions||0) + '</div><div class="stat-label">Impressions</div></div></div></div>' : ''}
|
|
360
|
+
\${p.media_urls && p.media_urls.length ? '<div style="margin-top:12px"><strong>Media:</strong> ' + p.media_urls.map(u => '<a href="' + esc(u) + '" target="_blank" style="color:#10b981">' + esc(u) + '</a>').join(', ') + '</div>' : ''}
|
|
361
|
+
</div>
|
|
362
|
+
\`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function renderCalendar() {
|
|
366
|
+
const cal = await api('/api/calendar');
|
|
367
|
+
const dates = Object.keys(cal).sort();
|
|
368
|
+
|
|
369
|
+
if (dates.length === 0) {
|
|
370
|
+
app.innerHTML = '<div class="empty">No scheduled posts.</div>';
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
app.innerHTML = dates.map(date => \`
|
|
375
|
+
<div class="cal-date">
|
|
376
|
+
<div class="cal-date-header">\${date}</div>
|
|
377
|
+
\${cal[date].map(p => \`
|
|
378
|
+
<div class="cal-post">
|
|
379
|
+
<span class="cal-time">\${p.scheduled_at ? p.scheduled_at.split(' ')[1] || p.scheduled_at.split('T')[1] || '' : ''}</span>
|
|
380
|
+
\${esc(p.content.substring(0, 80))}\${p.content.length > 80 ? '...' : ''}
|
|
381
|
+
</div>
|
|
382
|
+
\`).join('')}
|
|
383
|
+
</div>
|
|
384
|
+
\`).join('');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function renderMentions() {
|
|
388
|
+
const mentions = await api('/api/mentions');
|
|
389
|
+
|
|
390
|
+
if (mentions.length === 0) {
|
|
391
|
+
app.innerHTML = '<div class="empty">No mentions.</div>';
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const unreadCount = mentions.filter(m => !m.read).length;
|
|
396
|
+
app.innerHTML = \`
|
|
397
|
+
<div class="stats" style="margin-bottom:16px">
|
|
398
|
+
<div class="stat"><div class="stat-value">\${mentions.length}</div><div class="stat-label">Total Mentions</div></div>
|
|
399
|
+
<div class="stat"><div class="stat-value">\${unreadCount}</div><div class="stat-label">Unread</div></div>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="list">\${mentions.map(m => \`
|
|
402
|
+
<div class="mention \${m.read ? '' : 'unread'}">
|
|
403
|
+
<div class="mention-author">\${m.author_handle ? '@' + esc(m.author_handle) : esc(m.author) || 'Unknown'}</div>
|
|
404
|
+
<div class="mention-content">\${esc(m.content) || '(no content)'}</div>
|
|
405
|
+
<div class="mention-meta">
|
|
406
|
+
\${m.type ? '<span class="badge badge-draft">' + m.type + '</span>' : ''}
|
|
407
|
+
<span>\${esc(m.platform)}</span>
|
|
408
|
+
\${m.sentiment ? '<span>Sentiment: ' + esc(m.sentiment) + '</span>' : ''}
|
|
409
|
+
<span>\${esc(m.fetched_at)}</span>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
\`).join('')}</div>
|
|
413
|
+
\`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function renderAccounts() {
|
|
417
|
+
const accounts = await api('/api/accounts');
|
|
418
|
+
|
|
419
|
+
if (accounts.length === 0) {
|
|
420
|
+
app.innerHTML = '<div class="empty">No accounts connected.</div>';
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
app.innerHTML = \`
|
|
425
|
+
<div class="grid-2">\${accounts.map(a => \`
|
|
426
|
+
<div class="account-card">
|
|
427
|
+
<div class="account-platform">\${esc(a.platform)}</div>
|
|
428
|
+
<div class="account-handle">@\${esc(a.handle)}</div>
|
|
429
|
+
\${a.display_name ? '<div style="font-size:0.85rem;color:#aaa">' + esc(a.display_name) + '</div>' : ''}
|
|
430
|
+
<div style="margin-top:6px;font-size:0.8rem" class="\${a.connected ? 'connected' : 'disconnected'}">\${a.connected ? 'Connected' : 'Disconnected'}</div>
|
|
431
|
+
</div>
|
|
432
|
+
\`).join('')}</div>
|
|
433
|
+
\`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
render();
|
|
437
|
+
</script>
|
|
438
|
+
</body>
|
|
439
|
+
</html>`;
|
|
440
|
+
|
|
441
|
+
export { server };
|
package/package.json
CHANGED