somratpro Claude Sonnet 4.6 commited on
Commit
245f89d
·
1 Parent(s): 57b8b04

dashboard: user-friendly setup guide + platform status grid

Browse files

- Add getSocialPlatforms() — categorises all Postiz channels into
'works immediately' (no OAuth: Bluesky, Mastodon, Telegram, Dev.to…)
and 'needs API keys' (OAuth: LinkedIn, X, YouTube, TikTok, Reddit…)
- New renderDashboard(): boot-aware Open Postiz button, 4-step getting
started guide, collapsible platform cards with direct developer-portal
links, collapsed System & Backup section
- Auto-refreshes every 30s; button activates automatically when Postiz
comes online (no manual reload needed)
- Space Settings deep-link uses SPACE_ID env var for one-click nav to
secrets page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. health-server.js +298 -260
health-server.js CHANGED
@@ -46,6 +46,58 @@ const SYNC_INTERVAL = process.env.SYNC_INTERVAL || "300";
46
  const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingpost-uptimerobot-status.json";
47
  const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  // ============================================================================
50
  // URL helpers
51
  // ============================================================================
@@ -122,287 +174,273 @@ function formatUptime(seconds) {
122
  // ============================================================================
123
 
124
  function renderDashboard(initialData) {
125
- const uptimerobotStatus = getUptimeRobotStatus();
126
- let keepAwakeHtml;
127
- if (uptimerobotStatus?.configured) {
128
- keepAwakeHtml = `<div class="helper-summary success">
129
- <span class="status-badge status-online"><div class="pulse"></div>Configured</span>
130
- <span>UptimeRobot monitor active for <code>${uptimerobotStatus.url || "your /health endpoint"}</code>.</span>
131
- </div>`;
132
- } else if (uptimerobotStatus?.configured === false) {
133
- keepAwakeHtml = `<div class="helper-summary error">
134
- <span class="status-badge status-error">Failed</span>
135
- <span>Monitor setup failed. Check Space logs.</span>
136
- </div>`;
137
- } else if (UPTIMEROBOT_API_KEY_SET) {
138
- keepAwakeHtml = `<div class="helper-summary"><span class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Setting up</span> Setting up UptimeRobot monitor...</div>`;
139
- } else {
140
- keepAwakeHtml = `<div class="helper-summary">
141
- <strong>Not configured.</strong> Add <code>UPTIMEROBOT_API_KEY</code> to Space secrets to enable keep-awake monitoring.
142
- </div>`;
143
- }
144
-
145
  const syncStatus = initialData.sync;
146
  const hasBackup = HF_BACKUP_ENABLED;
147
  const lastSync = syncStatus.last_sync_time ? new Date(syncStatus.last_sync_time).toLocaleString() : "Never";
148
  const syncError = syncStatus.last_error || null;
 
 
 
 
149
 
150
  const syncBadge = !hasBackup
151
- ? `<div class="status-badge status-offline">Disabled</div>`
152
  : syncError
153
- ? `<div class="status-badge status-error">Error</div>`
154
  : syncStatus.last_sync_time
155
- ? `<div class="status-badge status-online"><div class="pulse"></div>Enabled</div>`
156
- : `<div class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Pending</div>`;
157
 
158
  const postizBadge = initialData.postizRunning
159
- ? `<div class="status-badge status-online"><div class="pulse"></div>Running</div>`
160
- : `<div class="status-badge status-offline">Booting</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  return `<!DOCTYPE html>
163
  <html lang="en">
164
  <head>
165
- <meta charset="UTF-8">
166
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
167
- <title>HuggingPost Dashboard</title>
168
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
169
- <style>
170
- :root {
171
- --bg: #0f172a;
172
- --card-bg: rgba(30, 41, 59, 0.7);
173
- --accent: linear-gradient(135deg, #ec4899, #8b5cf6);
174
- --text: #f8fafc;
175
- --text-dim: #94a3b8;
176
- --success: #10b981;
177
- --error: #ef4444;
178
- --warning: #f59e0b;
179
- }
180
- * { box-sizing: border-box; margin: 0; padding: 0; }
181
- body {
182
- font-family: 'Outfit', sans-serif;
183
- background-color: var(--bg);
184
- color: var(--text);
185
- display: flex;
186
- justify-content: center;
187
- align-items: flex-start;
188
- min-height: 100vh;
189
- padding: 24px 0;
190
- background-image:
191
- radial-gradient(at 0% 0%, rgba(236, 72, 153, 0.15) 0px, transparent 50%),
192
- radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
193
- }
194
- .dashboard {
195
- width: 90%; max-width: 600px;
196
- background: var(--card-bg);
197
- backdrop-filter: blur(12px);
198
- border: 1px solid rgba(255,255,255,0.1);
199
- border-radius: 24px; padding: 40px;
200
- box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
201
- animation: fadeIn 0.8s ease-out;
202
- margin: 24px 0;
203
- }
204
- @keyframes fadeIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } }
205
- header { text-align: center; margin-bottom: 40px; }
206
- h1 {
207
- font-size: 2.5rem; margin-bottom: 8px;
208
- background: var(--accent);
209
- -webkit-background-clip: text;
210
- -webkit-text-fill-color: transparent;
211
- font-weight: 600;
212
- }
213
- .subtitle { color: var(--text-dim); font-size: 0.9rem; letter-spacing: 1px; text-transform: uppercase; }
214
- .stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; margin-bottom: 20px; }
215
- .stat-card {
216
- background: rgba(255,255,255,0.03);
217
- border: 1px solid rgba(255,255,255,0.05);
218
- padding: 20px; border-radius: 16px;
219
- transition: transform 0.3s ease, border-color 0.3s ease;
220
- }
221
- .stat-card:hover { transform: translateY(-3px); border-color: rgba(236,72,153,0.3); }
222
- .stat-label { color: var(--text-dim); font-size: 0.75rem; text-transform: uppercase; margin-bottom: 8px; display: block; }
223
- .stat-value { font-size: 1.1rem; font-weight: 600; }
224
- .stat-btn {
225
- grid-column: span 2;
226
- background: var(--accent);
227
- color: #fff; padding: 16px;
228
- border-radius: 16px; text-align: center;
229
- text-decoration: none; font-weight: 600;
230
- display: block;
231
- transition: transform 0.3s ease, box-shadow 0.3s ease;
232
- box-shadow: 0 10px 20px -5px rgba(236,72,153,0.4);
233
- }
234
- .stat-btn:hover { transform: scale(1.02); box-shadow: 0 15px 30px -5px rgba(236,72,153,0.6); }
235
- .status-badge {
236
- display: inline-flex; align-items: center; gap: 6px;
237
- padding: 4px 12px; border-radius: 20px;
238
- font-size: 0.8rem; font-weight: 600;
239
- }
240
- .status-online { background: rgba(16,185,129,0.1); color: var(--success); }
241
- .status-offline { background: rgba(239,68,68,0.1); color: var(--error); }
242
- .status-syncing { background: rgba(59,130,246,0.1); color: #3b82f6; }
243
- .status-error { background: rgba(239,68,68,0.1); color: var(--error); }
244
- .pulse {
245
- width: 8px; height: 8px; border-radius: 50%;
246
- background: currentColor;
247
- box-shadow: 0 0 0 0 rgba(16,185,129,0.7);
248
- animation: pulse 2s infinite;
249
- }
250
- @keyframes pulse {
251
- 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16,185,129,0.7); }
252
- 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(16,185,129,0); }
253
- 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16,185,129,0); }
254
- }
255
- .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
256
- .card-header .stat-label { margin-bottom: 0; }
257
- .sync-info { background: rgba(255,255,255,0.02); padding: 15px; border-radius: 12px; font-size: 0.85rem; color: var(--text-dim); margin-top: 10px; }
258
- #sync-msg { color: var(--text); display: block; margin-top: 4px; }
259
- .helper-card { width: 100%; margin-top: 20px; }
260
- .helper-copy { color: var(--text-dim); font-size: 0.92rem; line-height: 1.6; margin-top: 10px; }
261
- .helper-copy strong { color: var(--text); }
262
- .helper-row { display: flex; gap: 10px; margin-top: 16px; flex-wrap: wrap; }
263
- .helper-input {
264
- flex: 1; min-width: 240px;
265
- background: rgba(255,255,255,0.04);
266
- border: 1px solid rgba(255,255,255,0.08);
267
- color: var(--text); border-radius: 12px;
268
- padding: 14px 16px; font: inherit;
269
- }
270
- .helper-input::placeholder { color: var(--text-dim); }
271
- .helper-button {
272
- background: var(--accent); color: #fff; border: 0;
273
- border-radius: 12px; padding: 14px 18px;
274
- font: inherit; font-weight: 600; cursor: pointer; min-width: 180px;
275
- }
276
- .helper-button:disabled { opacity: 0.6; cursor: wait; }
277
- .hidden { display: none !important; }
278
- .helper-note { margin-top: 10px; font-size: 0.82rem; color: var(--text-dim); }
279
- .helper-result { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 0.9rem; display: none; }
280
- .helper-result.ok { display: block; background: rgba(16,185,129,0.1); color: var(--success); }
281
- .helper-result.error { display: block; background: rgba(239,68,68,0.1); color: var(--error); }
282
- .helper-shell { margin-top: 12px; }
283
- .helper-shell.hidden { display: none; }
284
- .helper-summary {
285
- margin-top: 14px; padding: 12px 14px; border-radius: 12px;
286
- background: rgba(255,255,255,0.03); color: var(--text-dim);
287
- font-size: 0.9rem; line-height: 1.5;
288
- display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
289
- }
290
- .helper-summary strong { color: var(--text); }
291
- .helper-summary code { background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 4px; font-size: 0.85em; color: var(--text); }
292
- .helper-summary.success { background: rgba(16,185,129,0.08); }
293
- .helper-summary.error { background: rgba(239,68,68,0.08); }
294
- .footer { text-align: center; color: var(--text-dim); font-size: 0.8rem; margin-top: 20px; }
295
- @media (max-width: 700px) {
296
- body { padding: 16px 0; }
297
- .dashboard { width: calc(100% - 24px); padding: 24px; border-radius: 18px; margin: 12px 0; }
298
- header { margin-bottom: 28px; }
299
- h1 { font-size: 2rem; }
300
- .stats-grid { grid-template-columns: 1fr; gap: 14px; margin-bottom: 16px; }
301
- .stat-btn { grid-column: span 1; }
302
- .helper-row { flex-direction: column; }
303
- .helper-input, .helper-button { width: 100%; min-width: 0; }
304
- }
305
- </style>
306
  </head>
307
  <body>
308
- <div class="dashboard">
309
- <header>
310
- <h1>📮 HuggingPost</h1>
311
- <p class="subtitle">Postiz on HF Spaces</p>
312
- </header>
313
-
314
- <div class="stats-grid">
315
- <div class="stat-card">
316
- <div class="card-header">
317
- <span class="stat-label">Postiz</span>
318
- <span id="postiz-badge">${postizBadge}</span>
319
- </div>
320
- <div style="margin-top: 8px; font-size: 0.82rem; color: var(--text-dim);">
321
- Mounted at <strong style="color:var(--text)">/app</strong> · <a href="/app/" style="color:#f472b6;text-decoration:none;" target="_blank">Open UI →</a>
322
- </div>
323
- </div>
324
- <div class="stat-card">
325
- <span class="stat-label">Uptime</span>
326
- <span class="stat-value" id="uptime">${formatUptime(Math.floor((Date.now() - startTime) / 1000))}</span>
327
- </div>
328
- <div class="stat-card">
329
- <div class="card-header">
330
- <span class="stat-label">Backup</span>
331
- <span id="sync-badge">${syncBadge}</span>
332
- </div>
333
- <div style="margin-top: 8px; font-size: 0.82rem; color: var(--text-dim);">
334
- Last sync: <span id="last-sync">${lastSync}</span>
335
- </div>
336
- </div>
337
- <div class="stat-card">
338
- <span class="stat-label">Database</span>
339
- <span class="stat-value" id="db-status">${syncStatus.db_status === "connected" ? "PostgreSQL ✓" : syncStatus.db_status === "error" ? "Error" : "PostgreSQL"}</span>
340
- </div>
341
- <a href="/app/" id="open-ui-btn" class="stat-btn" target="_blank" rel="noopener noreferrer">Open Postiz →</a>
342
  </div>
343
-
344
- <div class="stat-card" style="width: 100%; margin-bottom: 20px;">
345
- <div class="card-header">
346
- <span class="stat-label">Backup Sync</span>
347
- <div id="sync-badge-detail">${syncBadge}</div>
348
- </div>
349
- <div class="sync-info">
350
- Last activity: <span id="sync-time-detail">${lastSync}</span>
351
- <span id="sync-msg">${syncError ? "Error: " + syncError : syncStatus.last_sync_time ? "Sync successful" : hasBackup ? "Waiting for first sync..." : "HF_TOKEN not set — backups disabled"}</span>
352
- </div>
353
  </div>
354
-
355
- <div class="stat-card helper-card">
356
- <span class="stat-label">Keep Space Awake</span>
357
- ${keepAwakeHtml}
 
358
  </div>
359
-
360
- <div class="footer">Live updates every 30s · Schedule posts only fire while the Space is awake</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
- <script>
364
- function getCurrentSearch() { return window.location.search || ''; }
365
-
366
- function renderSyncBadge(status, lastSyncTime, lastError) {
367
- if (!${hasBackup}) return '<div class="status-badge status-offline">Disabled</div>';
368
- if (lastError) return '<div class="status-badge status-error">Error</div>';
369
- if (lastSyncTime) return '<div class="status-badge status-online"><div class="pulse"></div>Enabled</div>';
370
- return '<div class="status-badge status-syncing"><div class="pulse" style="background:#3b82f6"></div>Pending</div>';
371
- }
372
-
373
- async function updateStatus() {
374
- try {
375
- const res = await fetch('/status' + getCurrentSearch());
376
- const data = await res.json();
377
- document.getElementById('uptime').textContent = data.uptime;
378
-
379
- const pbadge = data.postizRunning
380
- ? '<div class="status-badge status-online"><div class="pulse"></div>Running</div>'
381
- : '<div class="status-badge status-offline">Booting</div>';
382
- document.getElementById('postiz-badge').innerHTML = pbadge;
383
-
384
- const badge = renderSyncBadge(data.sync.db_status, data.sync.last_sync_time, data.sync.last_error);
385
- document.getElementById('sync-badge').innerHTML = badge;
386
- document.getElementById('sync-badge-detail').innerHTML = badge;
387
-
388
- const lastSync = data.sync.last_sync_time ? new Date(data.sync.last_sync_time).toLocaleString() : 'Never';
389
- document.getElementById('last-sync').textContent = lastSync;
390
- document.getElementById('sync-time-detail').textContent = lastSync;
391
-
392
- const syncMsg = data.sync.last_error ? 'Error: ' + data.sync.last_error
393
- : data.sync.last_sync_time ? 'Sync successful'
394
- : ${hasBackup} ? 'Waiting for first sync...' : 'HF_TOKEN not set — backups disabled';
395
- document.getElementById('sync-msg').textContent = syncMsg;
396
-
397
- const dbEl = document.getElementById('db-status');
398
- dbEl.textContent = data.sync.db_status === 'connected' ? 'PostgreSQL ✓'
399
- : data.sync.db_status === 'error' ? 'Error' : 'PostgreSQL';
400
- } catch (e) { console.error('Status update failed:', e); }
401
- }
402
-
403
- updateStatus();
404
- setInterval(updateStatus, 30000);
405
- </script>
 
 
 
 
 
406
  </body>
407
  </html>`;
408
  }
 
46
  const UPTIMEROBOT_STATUS_FILE = "/tmp/huggingpost-uptimerobot-status.json";
47
  const UPTIMEROBOT_API_KEY_SET = !!process.env.UPTIMEROBOT_API_KEY;
48
 
49
+ // Social platform env-var presence check (for dashboard status grid).
50
+ // Each entry: { name, emoji, ready: bool, setupUrl, envVars, noOAuth }
51
+ function getSocialPlatforms() {
52
+ const e = process.env;
53
+ return [
54
+ // ── Works immediately (connect inside Postiz UI, no env vars needed) ─────
55
+ { name: "Bluesky", emoji: "🦋", noOAuth: true, ready: true, note: "Username + App Password in Postiz" },
56
+ { name: "Mastodon", emoji: "🐘", noOAuth: true, ready: true, note: "Instance URL + credentials in Postiz" },
57
+ { name: "Telegram", emoji: "✈️", noOAuth: true, ready: true, note: "Bot token from @BotFather in Postiz" },
58
+ { name: "Nostr", emoji: "🔑", noOAuth: true, ready: true, note: "Private key in Postiz" },
59
+ { name: "Lemmy", emoji: "🐾", noOAuth: true, ready: true, note: "Instance + credentials in Postiz" },
60
+ { name: "Warpcast", emoji: "🟣", noOAuth: true, ready: true, note: "FID + private key in Postiz" },
61
+ { name: "Dev.to", emoji: "💻", noOAuth: true, ready: true, note: "API key from dev.to settings" },
62
+ { name: "Hashnode", emoji: "📰", noOAuth: true, ready: true, note: "API token from Hashnode settings" },
63
+ // ── Needs OAuth app (env vars required) ───────────────────────────────────
64
+ { name: "LinkedIn", emoji: "💼", ready: !!(e.LINKEDIN_CLIENT_ID && e.LINKEDIN_CLIENT_ID !== "undefined"),
65
+ setupUrl: "https://www.linkedin.com/developers/apps/new",
66
+ envVars: ["LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET"] },
67
+ { name: "X / Twitter",emoji: "🐦", ready: !!(e.X_API_KEY),
68
+ setupUrl: "https://developer.twitter.com/en/portal/projects-and-apps",
69
+ envVars: ["X_API_KEY", "X_API_SECRET"] },
70
+ { name: "Facebook", emoji: "📘", ready: !!(e.FACEBOOK_APP_ID),
71
+ setupUrl: "https://developers.facebook.com/apps/create/",
72
+ envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"] },
73
+ { name: "Instagram", emoji: "📸", ready: !!(e.FACEBOOK_APP_ID),
74
+ setupUrl: "https://developers.facebook.com/apps/create/",
75
+ envVars: ["FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"],
76
+ note: "Uses same app as Facebook" },
77
+ { name: "Threads", emoji: "🧵", ready: !!(e.THREADS_APP_ID),
78
+ setupUrl: "https://developers.facebook.com/apps/create/",
79
+ envVars: ["THREADS_APP_ID", "THREADS_APP_SECRET"] },
80
+ { name: "YouTube", emoji: "▶️", ready: !!(e.YOUTUBE_CLIENT_ID),
81
+ setupUrl: "https://console.cloud.google.com/apis/credentials",
82
+ envVars: ["YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET"] },
83
+ { name: "TikTok", emoji: "🎵", ready: !!(e.TIKTOK_CLIENT_ID),
84
+ setupUrl: "https://developers.tiktok.com/",
85
+ envVars: ["TIKTOK_CLIENT_ID", "TIKTOK_CLIENT_SECRET"] },
86
+ { name: "Reddit", emoji: "🤖", ready: !!(e.REDDIT_CLIENT_ID),
87
+ setupUrl: "https://www.reddit.com/prefs/apps",
88
+ envVars: ["REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET"] },
89
+ { name: "Pinterest", emoji: "📌", ready: !!(e.PINTEREST_CLIENT_ID),
90
+ setupUrl: "https://developers.pinterest.com/apps/",
91
+ envVars: ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"] },
92
+ { name: "Discord", emoji: "🎮", ready: !!(e.DISCORD_CLIENT_ID),
93
+ setupUrl: "https://discord.com/developers/applications",
94
+ envVars: ["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", "DISCORD_BOT_TOKEN_ID"] },
95
+ { name: "Slack", emoji: "💬", ready: !!(e.SLACK_ID),
96
+ setupUrl: "https://api.slack.com/apps?new_app=1",
97
+ envVars: ["SLACK_ID", "SLACK_SECRET", "SLACK_SIGNING_SECRET"] },
98
+ ];
99
+ }
100
+
101
  // ============================================================================
102
  // URL helpers
103
  // ============================================================================
 
174
  // ============================================================================
175
 
176
  function renderDashboard(initialData) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  const syncStatus = initialData.sync;
178
  const hasBackup = HF_BACKUP_ENABLED;
179
  const lastSync = syncStatus.last_sync_time ? new Date(syncStatus.last_sync_time).toLocaleString() : "Never";
180
  const syncError = syncStatus.last_error || null;
181
+ const platforms = getSocialPlatforms();
182
+ const readyNow = platforms.filter(p => p.noOAuth);
183
+ const needsSetup = platforms.filter(p => !p.noOAuth);
184
+ const configuredCount = needsSetup.filter(p => p.ready).length;
185
 
186
  const syncBadge = !hasBackup
187
+ ? `<span class="badge badge-off">Disabled</span>`
188
  : syncError
189
+ ? `<span class="badge badge-err">Error</span>`
190
  : syncStatus.last_sync_time
191
+ ? `<span class="badge badge-on"><i class="dot"></i>Syncing</span>`
192
+ : `<span class="badge badge-wait"><i class="dot" style="background:#3b82f6"></i>Pending</span>`;
193
 
194
  const postizBadge = initialData.postizRunning
195
+ ? `<span class="badge badge-on"><i class="dot"></i>Running</span>`
196
+ : `<span class="badge badge-off">Booting</span>`;
197
+
198
+ const needsSetupRows = needsSetup.map(p => {
199
+ if (p.ready) {
200
+ return `<div class="plat-row ready">
201
+ <span class="plat-icon">${p.emoji}</span>
202
+ <span class="plat-name">${p.name}</span>
203
+ <span class="badge badge-on" style="font-size:0.72rem">Configured</span>
204
+ </div>`;
205
+ }
206
+ return `<div class="plat-row">
207
+ <span class="plat-icon" style="filter:grayscale(1);opacity:.5">${p.emoji}</span>
208
+ <span class="plat-name" style="color:var(--dim)">${p.name}</span>
209
+ <a class="setup-link" href="${p.setupUrl}" target="_blank" rel="noopener">Get API keys →</a>
210
+ </div>`;
211
+ }).join("");
212
+
213
+ const readyNowRows = readyNow.map(p => `
214
+ <div class="plat-row ready">
215
+ <span class="plat-icon">${p.emoji}</span>
216
+ <span class="plat-name">${p.name}</span>
217
+ <span style="font-size:0.75rem;color:var(--dim)">${p.note || ""}</span>
218
+ </div>`).join("");
219
+
220
+ const uptimerobotStatus = getUptimeRobotStatus();
221
+ let keepAwakeNote;
222
+ if (uptimerobotStatus?.configured) {
223
+ keepAwakeNote = `<span class="badge badge-on" style="font-size:0.72rem"><i class="dot"></i>Monitor active</span>`;
224
+ } else if (UPTIMEROBOT_API_KEY_SET) {
225
+ keepAwakeNote = `<span class="badge badge-wait" style="font-size:0.72rem"><i class="dot" style="background:#3b82f6"></i>Setting up…</span>`;
226
+ } else {
227
+ keepAwakeNote = `<span style="color:var(--dim);font-size:0.8rem">Add <code>UPTIMEROBOT_API_KEY</code> secret to keep Space awake 24/7</span>`;
228
+ }
229
 
230
  return `<!DOCTYPE html>
231
  <html lang="en">
232
  <head>
233
+ <meta charset="UTF-8">
234
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
235
+ <title>HuggingPost Dashboard</title>
236
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
237
+ <style>
238
+ :root{--bg:#0f172a;--card:rgba(30,41,59,.75);--accent:linear-gradient(135deg,#ec4899,#8b5cf6);--text:#f8fafc;--dim:#94a3b8;--ok:#10b981;--err:#ef4444}
239
+ *{box-sizing:border-box;margin:0;padding:0}
240
+ body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:24px 12px;
241
+ background-image:radial-gradient(at 0% 0%,rgba(236,72,153,.15) 0,transparent 50%),radial-gradient(at 100% 0%,rgba(139,92,246,.15) 0,transparent 50%)}
242
+ .wrap{max-width:640px;margin:0 auto}
243
+ .card{background:var(--card);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:24px;margin-bottom:16px;
244
+ backdrop-filter:blur(12px);animation:up .5s ease}
245
+ @keyframes up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
246
+ header{text-align:center;margin-bottom:24px}
247
+ h1{font-size:2.2rem;font-weight:600;background:var(--accent);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
248
+ .sub{color:var(--dim);font-size:.85rem;letter-spacing:1px;text-transform:uppercase;margin-top:4px}
249
+ h2{font-size:.75rem;text-transform:uppercase;color:var(--dim);letter-spacing:.08em;margin-bottom:14px}
250
+ .open-btn{display:block;text-align:center;background:var(--accent);color:#fff;font-family:inherit;font-size:1rem;
251
+ font-weight:600;padding:16px;border-radius:14px;text-decoration:none;margin-bottom:16px;
252
+ box-shadow:0 8px 24px -6px rgba(236,72,153,.45);transition:transform .2s,box-shadow .2s}
253
+ .open-btn:hover{transform:scale(1.02);box-shadow:0 12px 30px -6px rgba(236,72,153,.6)}
254
+ .open-btn.booting{background:rgba(255,255,255,.07);color:var(--dim);box-shadow:none;cursor:wait}
255
+ .status-row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px}
256
+ .stat{flex:1;min-width:120px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
257
+ border-radius:14px;padding:14px 16px}
258
+ .stat-label{font-size:.7rem;text-transform:uppercase;color:var(--dim);margin-bottom:6px}
259
+ .stat-val{font-size:.95rem;font-weight:600}
260
+ .badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:.78rem;font-weight:600}
261
+ .badge-on{background:rgba(16,185,129,.12);color:var(--ok)}
262
+ .badge-off{background:rgba(239,68,68,.12);color:var(--err)}
263
+ .badge-wait{background:rgba(59,130,246,.12);color:#3b82f6}
264
+ .badge-err{background:rgba(239,68,68,.12);color:var(--err)}
265
+ .dot{width:7px;height:7px;border-radius:50%;background:currentColor;animation:pulse 2s infinite;flex-shrink:0}
266
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(16,185,129,.7)}70%{box-shadow:0 0 0 8px rgba(16,185,129,0)}100%{box-shadow:0 0 0 0 rgba(16,185,129,0)}}
267
+ .steps{counter-reset:step;list-style:none;padding:0}
268
+ .steps li{counter-increment:step;display:flex;gap:12px;align-items:flex-start;padding:10px 0;border-bottom:1px solid rgba(255,255,255,.04)}
269
+ .steps li:last-child{border-bottom:none}
270
+ .steps li::before{content:counter(step);min-width:24px;height:24px;border-radius:50%;background:var(--accent);
271
+ color:#fff;font-size:.72rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px}
272
+ .steps li .s-title{font-size:.9rem;font-weight:600;margin-bottom:2px}
273
+ .steps li .s-note{font-size:.8rem;color:var(--dim);line-height:1.5}
274
+ .steps li a{color:#f472b6;text-decoration:none}
275
+ .steps li a:hover{text-decoration:underline}
276
+ .section-toggle{width:100%;background:none;border:none;color:var(--text);font:inherit;font-size:.75rem;
277
+ text-transform:uppercase;letter-spacing:.08em;color:var(--dim);display:flex;align-items:center;
278
+ justify-content:space-between;cursor:pointer;padding:0;margin-bottom:14px}
279
+ .section-toggle svg{transition:transform .2s}
280
+ .section-toggle.open svg{transform:rotate(180deg)}
281
+ .collapse{display:none}.collapse.open{display:block}
282
+ .plat-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:.88rem}
283
+ .plat-row:last-child{border-bottom:none}
284
+ .plat-icon{font-size:1.1rem;width:24px;text-align:center;flex-shrink:0}
285
+ .plat-name{flex:1;font-weight:500}
286
+ .setup-link{color:#f472b6;font-size:.78rem;text-decoration:none;flex-shrink:0}
287
+ .setup-link:hover{text-decoration:underline}
288
+ .sync-note{font-size:.8rem;color:var(--dim);margin-top:8px}
289
+ code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:.85em}
290
+ .footer{text-align:center;color:var(--dim);font-size:.75rem;margin-top:8px;padding-bottom:24px}
291
+ @media(max-width:500px){h1{font-size:1.8rem}.status-row{gap:8px}.stat{padding:12px}}
292
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  </head>
294
  <body>
295
+ <div class="wrap">
296
+ <header>
297
+ <h1>📮 HuggingPost</h1>
298
+ <p class="sub">Self-hosted Postiz · Hugging Face Spaces</p>
299
+ </header>
300
+
301
+ <!-- Open Postiz button -->
302
+ ${initialData.postizRunning
303
+ ? `<a href="/app/" class="open-btn" target="_blank" rel="noopener">Open Postiz →</a>`
304
+ : `<a href="#" class="open-btn booting" onclick="return false">Postiz is starting up (first boot ~5 min)…</a>`}
305
+
306
+ <!-- Status row -->
307
+ <div class="status-row">
308
+ <div class="stat"><div class="stat-label">Postiz</div><div class="stat-val" id="postiz-badge">${postizBadge}</div></div>
309
+ <div class="stat"><div class="stat-label">Uptime</div><div class="stat-val" id="uptime">${formatUptime(Math.floor((Date.now() - startTime) / 1000))}</div></div>
310
+ <div class="stat"><div class="stat-label">Backup</div><div class="stat-val" id="sync-badge">${syncBadge}</div></div>
311
+ </div>
312
+
313
+ <!-- Getting Started -->
314
+ <div class="card">
315
+ <h2>🚀 Getting Started</h2>
316
+ <ol class="steps">
317
+ <li>
318
+ <div>
319
+ <div class="s-title">Create your account</div>
320
+ <div class="s-note">Click <strong>Open Postiz</strong> above. The first signup becomes the admin account.</div>
 
 
 
 
 
 
 
 
321
  </div>
322
+ </li>
323
+ <li>
324
+ <div>
325
+ <div class="s-title">Connect social accounts that work immediately</div>
326
+ <div class="s-note">Bluesky, Mastodon, Telegram, Dev.to, Hashnode and more connect with just your username — no developer setup needed. See the list below.</div>
 
 
 
 
 
327
  </div>
328
+ </li>
329
+ <li>
330
+ <div>
331
+ <div class="s-title">Enable LinkedIn, X, YouTube… (optional)</div>
332
+ <div class="s-note">These require a free API key from each platform. Go to the platform's developer portal, create an app, then add the keys as <a href="https://huggingface.co/spaces/${process.env.SPACE_ID || "your-space"}/settings" target="_blank">Space secrets</a>. See the platform list below for direct links.</div>
333
  </div>
334
+ </li>
335
+ <li>
336
+ <div>
337
+ <div class="s-title">Keep your Space awake (optional)</div>
338
+ <div class="s-note">HF Spaces sleep after inactivity — scheduled posts won't fire while sleeping. Add <code>UPTIMEROBOT_API_KEY</code> to auto-create a free uptime monitor, or upgrade to a paid HF Space.</div>
339
+ </div>
340
+ </li>
341
+ </ol>
342
+ </div>
343
+
344
+ <!-- Platforms ready now -->
345
+ <div class="card">
346
+ <button class="section-toggle open" onclick="toggle(this,'ready-list')">
347
+ ✅ Works immediately — no API keys needed (${readyNow.length} platforms)
348
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
349
+ </button>
350
+ <div id="ready-list" class="collapse open">
351
+ ${readyNowRows}
352
+ <div class="sync-note" style="margin-top:10px">Connect these inside Postiz → <strong>Add Channel</strong> after signing in.</div>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Platforms needing setup -->
357
+ <div class="card">
358
+ <button class="section-toggle open" onclick="toggle(this,'oauth-list')">
359
+ 🔑 Needs API keys — ${configuredCount}/${needsSetup.length} configured
360
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
361
+ </button>
362
+ <div id="oauth-list" class="collapse open">
363
+ ${needsSetupRows}
364
+ <div class="sync-note" style="margin-top:10px">
365
+ After getting API keys: go to your <a href="https://huggingface.co/spaces/${process.env.SPACE_ID || "your-space"}/settings" target="_blank" style="color:#f472b6">Space Settings → Variables & Secrets</a>, add the keys, then restart the Space.
366
+ </div>
367
  </div>
368
+ </div>
369
+
370
+ <!-- Backup & System -->
371
+ <div class="card">
372
+ <button class="section-toggle" onclick="toggle(this,'sys-detail')">
373
+ ⚙️ System &amp; Backup
374
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
375
+ </button>
376
+ <div id="sys-detail" class="collapse">
377
+ <div class="plat-row">
378
+ <span style="flex:1;font-size:.85rem">Database backup to HF Dataset</span>
379
+ <span id="sync-badge-detail">${syncBadge}</span>
380
+ </div>
381
+ <div class="plat-row">
382
+ <span style="flex:1;font-size:.85rem">Last sync</span>
383
+ <span style="font-size:.82rem;color:var(--dim)" id="sync-time-detail">${lastSync}</span>
384
+ </div>
385
+ <div class="plat-row">
386
+ <span style="flex:1;font-size:.85rem">Keep-awake monitor</span>
387
+ ${keepAwakeNote}
388
+ </div>
389
+ <div class="sync-note" id="sync-msg">${syncError ? "Backup error: " + syncError : syncStatus.last_sync_time ? "Last backup successful" : hasBackup ? "Waiting for first sync…" : "Add HF_TOKEN secret to enable automatic DB backups"}</div>
390
+ </div>
391
+ </div>
392
+
393
+ <div class="footer">Auto-refreshes every 30s · <a href="/health" style="color:var(--dim);text-decoration:none">Health endpoint</a></div>
394
+ </div>
395
 
396
+ <script>
397
+ function toggle(btn, id) {
398
+ btn.classList.toggle('open');
399
+ document.getElementById(id).classList.toggle('open');
400
+ }
401
+
402
+ function renderSyncBadge(hasBackup, lastSyncTime, lastError) {
403
+ if (!hasBackup) return '<span class="badge badge-off">Disabled</span>';
404
+ if (lastError) return '<span class="badge badge-err">Error</span>';
405
+ if (lastSyncTime) return '<span class="badge badge-on"><i class="dot"></i>Syncing</span>';
406
+ return '<span class="badge badge-wait"><i class="dot" style="background:#3b82f6"></i>Pending</span>';
407
+ }
408
+
409
+ async function refresh() {
410
+ try {
411
+ const d = await fetch('/status').then(r => r.json());
412
+ document.getElementById('uptime').textContent = d.uptime;
413
+
414
+ const running = d.postizRunning;
415
+ document.getElementById('postiz-badge').innerHTML = running
416
+ ? '<span class="badge badge-on"><i class="dot"></i>Running</span>'
417
+ : '<span class="badge badge-off">Booting…</span>';
418
+
419
+ const btn = document.querySelector('.open-btn');
420
+ if (btn && running && btn.classList.contains('booting')) {
421
+ btn.classList.remove('booting');
422
+ btn.textContent = 'Open Postiz →';
423
+ btn.href = '/app/';
424
+ btn.onclick = null;
425
+ }
426
+
427
+ const badge = renderSyncBadge(${hasBackup}, d.sync.last_sync_time, d.sync.last_error);
428
+ ['sync-badge','sync-badge-detail'].forEach(id => {
429
+ const el = document.getElementById(id);
430
+ if (el) el.innerHTML = badge;
431
+ });
432
+ const ls = d.sync.last_sync_time ? new Date(d.sync.last_sync_time).toLocaleString() : 'Never';
433
+ const el = document.getElementById('sync-time-detail');
434
+ if (el) el.textContent = ls;
435
+ const msg = document.getElementById('sync-msg');
436
+ if (msg) msg.textContent = d.sync.last_error ? 'Backup error: ' + d.sync.last_error
437
+ : d.sync.last_sync_time ? 'Last backup successful'
438
+ : ${hasBackup} ? 'Waiting for first sync…' : 'Add HF_TOKEN secret to enable automatic DB backups';
439
+ } catch(e) {}
440
+ }
441
+ refresh();
442
+ setInterval(refresh, 30000);
443
+ </script>
444
  </body>
445
  </html>`;
446
  }