social-autoposter 1.3.2 → 1.3.3

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/bin/server.js CHANGED
@@ -3571,6 +3571,185 @@ async function handleApi(req, res) {
3571
3571
  })().catch(e => json(res, { error: e.message }, 500));
3572
3572
  }
3573
3573
 
3574
+ // GET /api/activity/deletion-requests - list activity keys currently flagged
3575
+ // pending deletion. Dashboard pulls this on tab activation so the trash icon
3576
+ // renders in "pending" state for already-flagged rows. We expose only the
3577
+ // minimal fields the UI needs (key + status + requested_at) so this is
3578
+ // cheap to poll alongside /api/activity.
3579
+ if (p === '/api/activity/deletion-requests' && req.method === 'GET') {
3580
+ return (async () => {
3581
+ const rows = await pq(
3582
+ "SELECT activity_key, status, requested_at FROM deletion_requests " +
3583
+ "WHERE status = 'pending' ORDER BY requested_at DESC LIMIT 5000"
3584
+ );
3585
+ return json(res, { requests: rows || [] });
3586
+ })().catch(e => json(res, { error: e.message }, 500));
3587
+ }
3588
+
3589
+ // POST /api/activity/mark-deletion - flag an activity row for manual review
3590
+ // and email i@m13v.com with the details. We do NOT actually delete the
3591
+ // underlying post/reply/mention; the user reviews the email and decides.
3592
+ // Idempotent on activity_key (UNIQUE constraint -> ON CONFLICT DO NOTHING),
3593
+ // so a double-click or a re-fire after a page reload won't send a duplicate
3594
+ // email. The email body is built from the payload the dashboard already
3595
+ // has in hand so the server doesn't need to re-query the source table.
3596
+ if (p === '/api/activity/mark-deletion' && req.method === 'POST') {
3597
+ return readBody(req).then(async body => {
3598
+ let payload;
3599
+ try { payload = JSON.parse(body || '{}'); }
3600
+ catch { return json(res, { error: 'invalid JSON' }, 400); }
3601
+ const key = String(payload.key || '').trim();
3602
+ if (!key || key.length > 64) return json(res, { error: 'missing key' }, 400);
3603
+ // Map prefix -> kind label + numeric record id. Keys come from the
3604
+ // /api/activity SQL UNION and follow the convention prefix||id.
3605
+ // Longer prefixes must be matched before shorter ones (ktp before k).
3606
+ const PREFIXES = [
3607
+ ['ktp', 'seo_top_post'], ['kru', 'seo_roundup'], ['kt', 'seo_top_page'],
3608
+ ['kr', 'seo_reddit_page'], ['k', 'seo_serp_page'],
3609
+ ['rr', 'post_resurrected'], ['pi', 'seo_page_improvement'], ['xp', 'seo_expired_page'],
3610
+ ['dr', 'dm_reply'], ['d', 'dm'], ['s', 'reply_skipped'], ['r', 'reply'],
3611
+ ['m', 'mention'], ['p', 'post'], ['g', 'gsc_query'],
3612
+ ];
3613
+ let kind = 'unknown';
3614
+ let recordId = null;
3615
+ for (const [pref, label] of PREFIXES) {
3616
+ if (key.startsWith(pref) && /^\d+$/.test(key.slice(pref.length))) {
3617
+ kind = label;
3618
+ recordId = parseInt(key.slice(pref.length), 10);
3619
+ break;
3620
+ }
3621
+ }
3622
+ const platform = String(payload.platform || '').slice(0, 64);
3623
+ const project = String(payload.project || '').slice(0, 128);
3624
+ // Non-admin: only allow flagging rows on projects in their claim. Posts
3625
+ // tab is admin-only today, but the Activity tab is shared.
3626
+ if (!req.user.admin) {
3627
+ const allowed = new Set(req.user.projects || []);
3628
+ if (!project || !allowed.has(project)) {
3629
+ return json(res, { error: 'project not allowed' }, 403);
3630
+ }
3631
+ }
3632
+ const link = String(payload.link || '').slice(0, 2000);
3633
+ const summary = String(payload.summary || '').slice(0, 2000);
3634
+ const bodyText = String(payload.body || '').slice(0, 8000);
3635
+ const detail = String(payload.detail || '').slice(0, 500);
3636
+ const eventType = String(payload.type || '').slice(0, 64);
3637
+ const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : null;
3638
+ const requestedBy = (req.user && (req.user.email || req.user.sub)) || 'dashboard';
3639
+
3640
+ // Insert (or fetch the existing row if already pending). Idempotent.
3641
+ const insRows = await pq(
3642
+ "INSERT INTO deletion_requests " +
3643
+ "(activity_key, kind, record_id, platform, project, link, summary, occurred_at, requested_by, status) " +
3644
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') " +
3645
+ "ON CONFLICT (activity_key) DO NOTHING " +
3646
+ "RETURNING id, email_sent_at",
3647
+ [key, kind, recordId, platform, project, link, summary, occurredAt, requestedBy]
3648
+ );
3649
+ let alreadyExisted = false;
3650
+ let drId, emailSentAt;
3651
+ if (insRows && insRows.length) {
3652
+ drId = insRows[0].id;
3653
+ emailSentAt = insRows[0].email_sent_at;
3654
+ } else {
3655
+ alreadyExisted = true;
3656
+ const ex = await pq(
3657
+ "SELECT id, email_sent_at, requested_at FROM deletion_requests WHERE activity_key = $1",
3658
+ [key]
3659
+ );
3660
+ if (ex && ex.length) {
3661
+ drId = ex[0].id;
3662
+ emailSentAt = ex[0].email_sent_at;
3663
+ }
3664
+ }
3665
+
3666
+ // Only attempt to send the notification email when we don't already
3667
+ // have one on file for this row. This is the second idempotency gate
3668
+ // (race-safe under the UNIQUE constraint above).
3669
+ let emailStatus = alreadyExisted && emailSentAt ? 'already_sent' : 'pending';
3670
+ if (!emailSentAt) {
3671
+ try {
3672
+ const env = loadEnv();
3673
+ const resendKey = (env.RESEND_API_KEY || process.env.RESEND_API_KEY || '').trim();
3674
+ const notifyTo = (env.NOTIFICATION_EMAIL || process.env.NOTIFICATION_EMAIL || 'i@m13v.com').trim();
3675
+ if (!resendKey) {
3676
+ emailStatus = 'no_api_key';
3677
+ await pq(
3678
+ "UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
3679
+ ['RESEND_API_KEY missing in .env', drId]
3680
+ );
3681
+ } else {
3682
+ const fromAddr = 'Social Autoposter <matt@mail.omi.me>';
3683
+ const subj = 'Deletion request: ' + (platform || 'unknown') + ' / ' +
3684
+ (project || 'unknown') + ' / ' + (kind || 'unknown') + ' #' + (recordId || '?');
3685
+ const occurredStr = occurredAt ? occurredAt.toISOString() : '(unknown)';
3686
+ // Plain text; no dashes (per global CLAUDE.md rule). Resend
3687
+ // recommends both html and text but text alone is fine.
3688
+ const textBody =
3689
+ 'A row was flagged for deletion from the social autoposter dashboard.\n\n' +
3690
+ 'Activity key: ' + key + '\n' +
3691
+ 'Kind: ' + kind + '\n' +
3692
+ 'Record id: ' + (recordId == null ? '(unknown)' : recordId) + '\n' +
3693
+ 'Event type: ' + (eventType || '(unknown)') + '\n' +
3694
+ 'Platform: ' + (platform || '(unknown)') + '\n' +
3695
+ 'Project: ' + (project || '(unknown)') + '\n' +
3696
+ 'Occurred at: ' + occurredStr + '\n' +
3697
+ 'Link: ' + (link || '(none)') + '\n' +
3698
+ 'Detail: ' + (detail || '(none)') + '\n' +
3699
+ 'Requested by: ' + requestedBy + '\n\n' +
3700
+ 'Summary:\n' + (summary || '(none)') + '\n\n' +
3701
+ 'Body:\n' + (bodyText || '(none)') + '\n';
3702
+ const r = await fetch('https://api.resend.com/emails', {
3703
+ method: 'POST',
3704
+ headers: {
3705
+ 'Authorization': 'Bearer ' + resendKey,
3706
+ 'Content-Type': 'application/json',
3707
+ },
3708
+ body: JSON.stringify({
3709
+ from: fromAddr,
3710
+ to: [notifyTo],
3711
+ subject: subj,
3712
+ text: textBody,
3713
+ }),
3714
+ });
3715
+ const rj = await r.json().catch(() => ({}));
3716
+ if (r.ok && rj && rj.id) {
3717
+ await pq(
3718
+ "UPDATE deletion_requests SET email_sent_at = NOW(), email_id = $1 WHERE id = $2",
3719
+ [String(rj.id), drId]
3720
+ );
3721
+ emailStatus = 'sent';
3722
+ } else {
3723
+ const errMsg = (rj && (rj.message || rj.error)) || ('HTTP ' + r.status);
3724
+ await pq(
3725
+ "UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
3726
+ [String(errMsg).slice(0, 500), drId]
3727
+ );
3728
+ emailStatus = 'send_failed';
3729
+ console.error('[mark-deletion] resend failed:', errMsg);
3730
+ }
3731
+ }
3732
+ } catch (e) {
3733
+ emailStatus = 'send_failed';
3734
+ await pq(
3735
+ "UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
3736
+ [String(e.message || e).slice(0, 500), drId]
3737
+ ).catch(() => {});
3738
+ console.error('[mark-deletion] exception:', e.message);
3739
+ }
3740
+ }
3741
+
3742
+ return json(res, {
3743
+ status: 'pending',
3744
+ already_existed: alreadyExisted,
3745
+ email_status: emailStatus,
3746
+ key,
3747
+ kind,
3748
+ record_id: recordId,
3749
+ });
3750
+ }).catch(e => json(res, { error: e.message }, 500));
3751
+ }
3752
+
3574
3753
  // GET /api/style/stats - posts grouped by engagement_style over a trailing window (default 24h)
3575
3754
  if (p === '/api/style/stats' && req.method === 'GET') {
3576
3755
  const url = new URL(req.url, 'http://localhost');
@@ -5674,6 +5853,42 @@ const HTML = `<!DOCTYPE html>
5674
5853
  .reply-text { color: var(--text); margin-top: 2px; }
5675
5854
  .hidden { display: none; }
5676
5855
 
5856
+ /* Mark-for-deletion trash button (Activity + Top tabs).
5857
+ States:
5858
+ .sa-del-btn default trash icon (greyed out, low opacity)
5859
+ .sa-del-btn:hover red glow, full opacity
5860
+ .sa-del-btn.is-loading spinner overlay, button disabled
5861
+ .sa-del-btn.is-pending amber color, tooltip "pending deletion"
5862
+ The button text/label is intentionally absent; the icon shows on hover
5863
+ via title/data-tooltip. */
5864
+ .sa-del-btn {
5865
+ display: inline-flex; align-items: center; justify-content: center;
5866
+ width: 24px; height: 24px; padding: 0;
5867
+ background: transparent; border: 1px solid transparent; border-radius: 6px;
5868
+ color: var(--text-muted, #888); cursor: pointer; opacity: 0.5;
5869
+ transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
5870
+ line-height: 1; font-size: 14px;
5871
+ vertical-align: middle;
5872
+ }
5873
+ .sa-del-btn:hover { opacity: 1; color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
5874
+ .sa-del-btn:focus-visible { outline: 1px solid #ef4444; outline-offset: 1px; opacity: 1; }
5875
+ .sa-del-btn svg { width: 14px; height: 14px; display: block; }
5876
+ .sa-del-btn.is-loading { pointer-events: none; opacity: 1; color: var(--cyan, #06b6d4); }
5877
+ .sa-del-btn.is-loading svg { opacity: 0; }
5878
+ .sa-del-btn.is-loading::after {
5879
+ content: ''; position: absolute; width: 12px; height: 12px;
5880
+ border: 2px solid rgba(6, 182, 212, 0.25); border-top-color: var(--cyan, #06b6d4);
5881
+ border-radius: 50%; animation: saDelSpin 0.8s linear infinite;
5882
+ }
5883
+ .sa-del-btn { position: relative; }
5884
+ @keyframes saDelSpin { to { transform: rotate(360deg); } }
5885
+ .sa-del-btn.is-pending {
5886
+ opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
5887
+ background: rgba(245, 158, 11, 0.08);
5888
+ }
5889
+ .sa-del-btn.is-pending:hover { color: #f59e0b; border-color: rgba(245, 158, 11, 0.55); }
5890
+ .sa-del-btn.is-failed { color: #ef4444; opacity: 1; border-color: rgba(239, 68, 68, 0.55); }
5891
+
5677
5892
  /* Activity tab */
5678
5893
  .activity-controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
5679
5894
  .activity-filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
@@ -6573,10 +6788,13 @@ const HTML = `<!DOCTYPE html>
6573
6788
  <th style="width:90px;text-align:right;" class="activity-sortable" data-sort="cost_usd">
6574
6789
  <span class="activity-header-label">Cost <span class="activity-sort-arrow" data-sort-arrow="cost_usd"></span></span>
6575
6790
  </th>
6791
+ <th style="width:40px;text-align:center;">
6792
+ <span class="activity-header-label" title="Mark for deletion">&nbsp;</span>
6793
+ </th>
6576
6794
  </tr>
6577
6795
  </thead>
6578
6796
  <tbody id="activity-body">
6579
- <tr><td colspan="5" style="text-align:center;color:var(--text);padding:40px;">Loading&hellip;</td></tr>
6797
+ <tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">Loading&hellip;</td></tr>
6580
6798
  </tbody>
6581
6799
  </table>
6582
6800
  </div>
@@ -8848,6 +9066,126 @@ function escapeHtml(s) {
8848
9066
  return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
8849
9067
  }
8850
9068
 
9069
+ // ===== Mark-for-deletion helpers (Activity tab + Top tab) =====
9070
+ // _saDelPending is a client-side Set of activity keys (e.g. 'p123', 'r456')
9071
+ // known to already be flagged for deletion. Populated on tab activation by
9072
+ // loadDeletionRequests() and updated optimistically when the user clicks the
9073
+ // trash icon. We never delete from this set; pending is a one-way state.
9074
+ const _saDelPending = new Set();
9075
+ const _saDelFailed = new Set();
9076
+
9077
+ function renderDeleteBtnHtml(e) {
9078
+ if (!e || !e.key) return '';
9079
+ const isPending = _saDelPending.has(e.key);
9080
+ const isFailed = !isPending && _saDelFailed.has(e.key);
9081
+ // Stash the minimal payload the POST endpoint wants on the button itself
9082
+ // so the click handler can read it without re-querying state. Encoded
9083
+ // as a single base64 blob to keep HTML clean and skip per-field escaping.
9084
+ const payload = {
9085
+ key: e.key,
9086
+ type: e.type || '',
9087
+ platform: e.platform || '',
9088
+ project: e.project || '',
9089
+ link: e.link || '',
9090
+ summary: e.summary || '',
9091
+ body: e.body || '',
9092
+ detail: e.detail || '',
9093
+ occurred_at: e.occurred_at || null,
9094
+ };
9095
+ let b64 = '';
9096
+ try { b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); }
9097
+ catch { b64 = ''; }
9098
+ const title = isPending
9099
+ ? 'pending deletion'
9100
+ : (isFailed ? 'delete request failed, click to retry' : 'mark for deletion');
9101
+ const cls = 'sa-del-btn' + (isPending ? ' is-pending' : '') + (isFailed ? ' is-failed' : '');
9102
+ // Trash can SVG (Heroicons-style, 24x24 viewBox). currentColor for theming.
9103
+ const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
9104
+ '<path d="M3 6h18"/>' +
9105
+ '<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' +
9106
+ '<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
9107
+ '<path d="M10 11v6"/>' +
9108
+ '<path d="M14 11v6"/>' +
9109
+ '</svg>';
9110
+ return '<button type="button" class="' + cls + '" ' +
9111
+ 'data-sa-del="1" ' +
9112
+ 'data-sa-del-payload="' + b64 + '" ' +
9113
+ 'aria-label="' + escapeHtml(title) + '" ' +
9114
+ 'title="' + escapeHtml(title) + '">' + icon + '</button>';
9115
+ }
9116
+
9117
+ async function handleDeleteBtnClick(btn) {
9118
+ if (!btn || btn.classList.contains('is-loading')) return;
9119
+ // No-op if already pending (just give the hover tooltip).
9120
+ if (btn.classList.contains('is-pending')) return;
9121
+ let payload = null;
9122
+ try {
9123
+ const b64 = btn.getAttribute('data-sa-del-payload') || '';
9124
+ if (b64) payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
9125
+ } catch (e) { /* ignored - bail below */ }
9126
+ if (!payload || !payload.key) return;
9127
+ btn.classList.remove('is-failed');
9128
+ _saDelFailed.delete(payload.key);
9129
+ btn.classList.add('is-loading');
9130
+ btn.setAttribute('title', 'sending...');
9131
+ try {
9132
+ const r = await fetch('/api/activity/mark-deletion', {
9133
+ method: 'POST',
9134
+ headers: { 'Content-Type': 'application/json' },
9135
+ body: JSON.stringify(payload),
9136
+ });
9137
+ const j = await r.json().catch(() => ({}));
9138
+ btn.classList.remove('is-loading');
9139
+ if (r.ok && (j.status === 'pending' || j.already_existed)) {
9140
+ _saDelPending.add(payload.key);
9141
+ btn.classList.add('is-pending');
9142
+ btn.setAttribute('title', 'pending deletion');
9143
+ btn.setAttribute('aria-label', 'pending deletion');
9144
+ } else {
9145
+ _saDelFailed.add(payload.key);
9146
+ btn.classList.add('is-failed');
9147
+ const msg = (j && j.error) ? String(j.error) : ('HTTP ' + r.status);
9148
+ btn.setAttribute('title', 'failed: ' + msg + ' (click to retry)');
9149
+ }
9150
+ } catch (e) {
9151
+ btn.classList.remove('is-loading');
9152
+ _saDelFailed.add(payload.key);
9153
+ btn.classList.add('is-failed');
9154
+ btn.setAttribute('title', 'failed: ' + (e.message || 'network error') + ' (click to retry)');
9155
+ }
9156
+ }
9157
+
9158
+ async function loadDeletionRequests() {
9159
+ try {
9160
+ const r = await fetch('/api/activity/deletion-requests');
9161
+ if (!r.ok) return;
9162
+ const j = await r.json();
9163
+ const list = (j && j.requests) || [];
9164
+ list.forEach(row => {
9165
+ if (row && row.activity_key) _saDelPending.add(row.activity_key);
9166
+ });
9167
+ } catch (e) {
9168
+ // Silent - the buttons just stay in the default state until the user
9169
+ // refreshes the tab. No UX regression vs not having the feature.
9170
+ }
9171
+ }
9172
+
9173
+ // Global delegated click handler so every trash button on every tab works
9174
+ // without per-row wiring. Idempotent: re-wiring is a no-op because we set a
9175
+ // flag on document.body.
9176
+ function _saInstallDeleteListener() {
9177
+ if (document.body && document.body.dataset.saDelInstalled === '1') return;
9178
+ document.addEventListener('click', ev => {
9179
+ const btn = ev.target.closest && ev.target.closest('button[data-sa-del]');
9180
+ if (!btn) return;
9181
+ ev.preventDefault();
9182
+ ev.stopPropagation();
9183
+ handleDeleteBtnClick(btn);
9184
+ });
9185
+ if (document.body) document.body.dataset.saDelInstalled = '1';
9186
+ }
9187
+ // ===== end mark-for-deletion helpers =====
9188
+
8851
9189
  const PLATFORM_ICONS = {
8852
9190
  reddit: '<svg viewBox="0 0 24 24" aria-label="reddit"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.436 13.158c.023.16.034.323.034.49 0 2.498-2.908 4.522-6.494 4.522-3.587 0-6.494-2.024-6.494-4.523 0-.167.011-.33.033-.489a1.44 1.44 0 01-.822-1.297 1.444 1.444 0 012.448-1.036 7.967 7.967 0 014.337-1.374l.82-3.865a.277.277 0 01.328-.215l2.69.57a1.004 1.004 0 011.813.068 1.005 1.005 0 01-1.813.875l-2.406-.51-.736 3.47a7.98 7.98 0 014.298 1.379 1.44 1.44 0 011.996.432c.35.56.2 1.29-.332 1.652-.02.013-.04.025-.06.037zM9.17 13.14a1.02 1.02 0 100-2.041 1.02 1.02 0 000 2.041zm6.69-1.02a1.02 1.02 0 10-2.04 0 1.02 1.02 0 002.04 0zm-1.01 3.32a.33.33 0 00-.467 0c-.56.56-1.63.605-1.944.605s-1.384-.046-1.944-.606a.33.33 0 00-.467.467c.887.887 2.587.957 2.411.957.176 0 1.524-.07 2.411-.957a.33.33 0 000-.466z"/></svg>',
8853
9191
  twitter: '<svg viewBox="0 0 24 24" aria-label="twitter"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
@@ -11583,9 +11921,29 @@ function renderTopPosts(payload) {
11583
11921
  filterMode: 'dropdown',
11584
11922
  filterOptions: ageThresholdOptions(),
11585
11923
  filterPredicate: filterPredicateAge },
11586
- { key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct: 48,
11924
+ { key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct: 46,
11587
11925
  formatter: renderTopContentCell,
11588
11926
  filterMode: 'none' },
11927
+ { key: 'id', label: '', type: 'text', align: 'center', widthPct: 2,
11928
+ // Per-row "mark for deletion" trash button. The Top tab only shows
11929
+ // posts, so we synthesize an Activity-style event key with the 'p'
11930
+ // prefix and pass through the platform/project/content the email
11931
+ // would want to surface. The button itself + click handler live in
11932
+ // the shared sa-del-btn helpers.
11933
+ formatter: (_v, r) => {
11934
+ const ev = {
11935
+ key: 'p' + r.id,
11936
+ type: r.is_thread ? 'posted_thread' : 'posted_comment',
11937
+ platform: r.platform || '',
11938
+ project: r.project_name || '',
11939
+ link: r.our_url || '',
11940
+ summary: r.our_content || '',
11941
+ body: r.our_content || '',
11942
+ occurred_at: r.posted_at || null,
11943
+ };
11944
+ return renderDeleteBtnHtml(ev);
11945
+ },
11946
+ filterMode: 'none' },
11589
11947
  ],
11590
11948
  });
11591
11949
  }
@@ -13449,7 +13807,7 @@ function renderActivity(events) {
13449
13807
  sorted.length + ' of ' + events.length + ' events';
13450
13808
  renderPagination(sorted.length);
13451
13809
  if (!page.length) {
13452
- body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text);padding:40px;">No matching events</td></tr>';
13810
+ body.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">No matching events</td></tr>';
13453
13811
  return;
13454
13812
  }
13455
13813
  const rows = page.map(e => {
@@ -13527,6 +13885,7 @@ function renderActivity(events) {
13527
13885
  '</td>' +
13528
13886
  '<td class="activity-summary">' + summaryHtml + '</td>' +
13529
13887
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
13888
+ '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
13530
13889
  '</tr>';
13531
13890
  }).join('');
13532
13891
  body.innerHTML = rows;
@@ -13582,6 +13941,9 @@ function activateTab(name) {
13582
13941
  if (name === 'activity') {
13583
13942
  buildActivityFilters();
13584
13943
  if (!_tabLoaded.activity) _tabLoaded.activity = true;
13944
+ // Refresh the pending-deletion Set so the trash icon renders in
13945
+ // "pending" state for already-flagged rows on tab activation.
13946
+ loadDeletionRequests();
13585
13947
  startActivityAutoRefresh();
13586
13948
  } else {
13587
13949
  stopActivityAutoRefresh();
@@ -13597,6 +13959,9 @@ function activateTab(name) {
13597
13959
  }
13598
13960
  if (name === 'top') {
13599
13961
  initTopFilters();
13962
+ // Same pending-deletion refresh as Activity: makes the trash icon
13963
+ // show the right state immediately when the user lands on Top.
13964
+ loadDeletionRequests();
13600
13965
  if (_topSubtab === 'pages') {
13601
13966
  loadTopPages();
13602
13967
  } else if (_topSubtab === 'dms') {
@@ -13623,6 +13988,10 @@ document.querySelectorAll('.tab').forEach(tab => {
13623
13988
  try { window.posthog && window.posthog.capture('tab_view', { tab: name }); } catch (e) {}
13624
13989
  });
13625
13990
  });
13991
+ // Install the global delegated click handler for "mark for deletion" trash
13992
+ // buttons. Idempotent (early-exits if already installed). Runs once the body
13993
+ // is parsed so the listener is in place before any tab render fires.
13994
+ _saInstallDeleteListener();
13626
13995
  // On first paint, restore the persisted main tab. The HTML defaults to
13627
13996
  // "stats" — only override if the saved value is a known tab and is currently
13628
13997
  // visible to the user (some tabs are admin-only).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3.11
2
+ """One-shot insert/update for post-020 (TLH-lesson-13, organic type)."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import psycopg2
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv(Path.home() / "social-autoposter" / ".env")
11
+
12
+ POST_NUMBER = 20
13
+ VARIANT_ID = "lesson-13"
14
+ COMPOSITION_ID = "TLH-lesson-13"
15
+ VIDEO_PATH = str(Path.home() / "social-autoposter/mixer/remotion/out/post-020.mp4")
16
+ CAPTION_PATH = Path.home() / "social-autoposter/mixer/remotion/out/post-020.caption.txt"
17
+ AUDIO_SOURCE = "local:~/social-autoposter/mixer/audio/track-020_demo-bgm.m4a"
18
+ THEME_ANGLE = "ai-cofounder"
19
+ UNPROVEN_CLIP_BASENAME = "91CCEBC8-DCFC-4F0F-8B2B-3B064647ABCF.MP4"
20
+ UNPROVEN_CLIP_PATH = (
21
+ "/Users/matthewdi/social-autoposter/unproven new content/"
22
+ + UNPROVEN_CLIP_BASENAME
23
+ )
24
+
25
+ clips = [
26
+ {
27
+ "src": "mixer/tlh-13-1.mp4",
28
+ "raw_src": (
29
+ "/Users/matthewdi/social-autoposter/mixer/unproven new content/"
30
+ + UNPROVEN_CLIP_BASENAME
31
+ ),
32
+ "target_dur_sec": 2.0,
33
+ "src_dur_sec": 8.0,
34
+ "speedup": 4.0,
35
+ },
36
+ {"src": "mixer/tlh-4-3.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
37
+ {"src": "mixer/tlh-6-4.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
38
+ {"src": "mixer/tlh-7-1.mp4", "target_dur_sec": 2.0, "src_dur_sec": 2.0, "speedup": 1.0},
39
+ ]
40
+ source_clips = []
41
+ t = 0.0
42
+ for i, c in enumerate(clips, 1):
43
+ entry = {
44
+ "order": i,
45
+ "src": c["src"],
46
+ "src_dur_sec": c["src_dur_sec"],
47
+ "target_dur_sec": c["target_dur_sec"],
48
+ "speedup": c["speedup"],
49
+ "start_sec": round(t, 3),
50
+ "end_sec": round(t + c["target_dur_sec"], 3),
51
+ }
52
+ if "raw_src" in c:
53
+ entry["raw_src"] = c["raw_src"]
54
+ source_clips.append(entry)
55
+ t += c["target_dur_sec"]
56
+ total_sec = round(t, 3)
57
+
58
+ overlay_lines = [
59
+ "i looked for a cofounder for 6 years.",
60
+ "claude argued back better than humans.",
61
+ "i shipped 3 features in one weekend.",
62
+ "the cofounder was never the moat.",
63
+ ]
64
+ overlays = []
65
+ for i, text in enumerate(overlay_lines):
66
+ s = round(i * 2.0, 3)
67
+ overlays.append({
68
+ "order": i + 1,
69
+ "text": text,
70
+ "start_sec": s,
71
+ "end_sec": round(s + 2.0, 3),
72
+ "dur_sec": 2.0,
73
+ })
74
+
75
+ metadata = {
76
+ "theme": "ai",
77
+ "format": "tlh",
78
+ "clip_count": len(source_clips),
79
+ "source_repo": "social-autoposter",
80
+ "theme_angle": THEME_ANGLE,
81
+ "theme_label": "ai-cofounder",
82
+ "caption_style": "v1-here-is-a-story",
83
+ "overlay_count": len(overlays),
84
+ "composition_id": COMPOSITION_ID,
85
+ "description_style": "narrative-story-arc",
86
+ "unproven_clip_basename": UNPROVEN_CLIP_BASENAME,
87
+ }
88
+
89
+ caption_text = CAPTION_PATH.read_text(encoding="utf-8")
90
+
91
+ conn = psycopg2.connect(os.environ["DATABASE_URL"])
92
+ cur = conn.cursor()
93
+
94
+ cur.execute("SELECT post_number FROM media_posts WHERE post_number=%s", (POST_NUMBER,))
95
+ exists = cur.fetchone() is not None
96
+
97
+ if exists:
98
+ cur.execute(
99
+ """
100
+ UPDATE media_posts SET
101
+ variant_id=%s,
102
+ project_name=%s,
103
+ post_type=%s,
104
+ video_path=%s,
105
+ audio_source=%s,
106
+ caption_text=%s,
107
+ caption_version='v1-story',
108
+ duration_sec=%s,
109
+ width=1080,
110
+ height=1920,
111
+ status='draft',
112
+ source_clips=%s::jsonb,
113
+ overlays=%s::jsonb,
114
+ metadata=%s::jsonb
115
+ WHERE post_number=%s
116
+ """,
117
+ (
118
+ VARIANT_ID, "fazm", "organic", VIDEO_PATH, AUDIO_SOURCE, caption_text,
119
+ total_sec, json.dumps(source_clips), json.dumps(overlays),
120
+ json.dumps(metadata), POST_NUMBER,
121
+ ),
122
+ )
123
+ print(f"UPDATED row post_number={POST_NUMBER}")
124
+ else:
125
+ cur.execute(
126
+ """
127
+ INSERT INTO media_posts
128
+ (post_number, variant_id, project_name, post_type, video_path, audio_source,
129
+ caption_text, caption_version, duration_sec, width, height, status,
130
+ source_clips, overlays, metadata)
131
+ VALUES
132
+ (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb)
133
+ """,
134
+ (
135
+ POST_NUMBER, VARIANT_ID, "fazm", "organic", VIDEO_PATH, AUDIO_SOURCE,
136
+ caption_text, "v1-story", total_sec, 1080, 1920, "draft",
137
+ json.dumps(source_clips), json.dumps(overlays), json.dumps(metadata),
138
+ ),
139
+ )
140
+ print(f"INSERTED row post_number={POST_NUMBER}")
141
+
142
+ conn.commit()
143
+ cur.close()
144
+ conn.close()
145
+ print(f"done: variant={VARIANT_ID} angle={THEME_ANGLE} unproven={UNPROVEN_CLIP_BASENAME}")