vidpipe 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.
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>vidpipe — Review Queue</title>
6
+ <title>vidpipe — Review Queue (Grouped)</title>
7
7
  <script src="https://cdn.tailwindcss.com"></script>
8
8
  <script>
9
9
  tailwind.config = {
@@ -42,15 +42,39 @@
42
42
  ::-webkit-scrollbar-track { background: #1a1a2e; }
43
43
  ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
44
44
 
45
- /* Character count bar */
46
- .char-bar { transition: width 0.3s ease, background-color 0.3s ease; }
45
+ /* Checkbox styling */
46
+ .platform-checkbox {
47
+ appearance: none;
48
+ width: 20px;
49
+ height: 20px;
50
+ border: 2px solid rgba(255, 255, 255, 0.2);
51
+ border-radius: 4px;
52
+ cursor: pointer;
53
+ position: relative;
54
+ transition: all 0.2s ease;
55
+ }
56
+ .platform-checkbox:checked {
57
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
58
+ border-color: #667eea;
59
+ }
60
+ .platform-checkbox:checked::after {
61
+ content: '✓';
62
+ position: absolute;
63
+ top: 50%;
64
+ left: 50%;
65
+ transform: translate(-50%, -50%);
66
+ color: white;
67
+ font-size: 14px;
68
+ font-weight: bold;
69
+ }
70
+ .platform-checkbox:hover {
71
+ border-color: rgba(255, 255, 255, 0.4);
72
+ }
47
73
 
48
74
  /* Action button hover effects */
49
75
  .action-btn { transition: all 0.15s ease; }
50
76
  .action-btn:hover { transform: translateY(-2px); }
51
77
  .action-btn:active { transform: translateY(0); }
52
-
53
- /* Focus ring for keyboard navigation */
54
78
  .action-btn:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
55
79
 
56
80
  /* Platform filter tab */
@@ -153,39 +177,15 @@
153
177
  <span class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">vidpipe</span>
154
178
  <span class="text-gray-500 text-sm">${subtitle}</span>
155
179
  </div>
156
- <div class="flex items-center gap-2 text-xs text-gray-500" aria-label="Keyboard shortcuts: Left arrow to reject, Right arrow to approve, E to edit, Space to skip">
180
+ <div class="flex items-center gap-2 text-xs text-gray-500" aria-label="Keyboard shortcuts">
157
181
  <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">←</kbd> Reject
158
182
  <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">→</kbd> Approve
159
- <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">E</kbd> Edit
160
183
  <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">Space</kbd> Skip
161
184
  </div>
162
185
  </div>
163
186
  </header>`
164
187
  }
165
188
 
166
- function PlatformTabs({ items, filter, onFilter }) {
167
- const platforms = ['all', ...new Set(items.map(i => normalizePlatform(i.metadata.platform)))]
168
- return html`
169
- <nav class="flex-shrink-0 border-b border-white/5 bg-surface/50">
170
- <div class="max-w-4xl mx-auto px-4 flex gap-1 overflow-x-auto">
171
- ${platforms.map(p => {
172
- const cfg = p === 'all' ? { icon: '📋', name: 'All' } : (PLATFORMS[p] || { icon: '❓', name: p })
173
- const count = p === 'all' ? items.length : items.filter(i => normalizePlatform(i.metadata.platform) === p).length
174
- const active = filter === p
175
- const color = p === 'all' ? '#60a5fa' : cfg.color
176
- const textColor = p === 'all' ? '#93c5fd' : cfg.color
177
- return html`
178
- <button
179
- class="platform-tab flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors border-b-2 border-transparent whitespace-nowrap ${active ? 'active text-gray-100' : ''}"
180
- style=${active ? `border-color: ${color}; color: ${textColor}` : ''}
181
- onClick=${() => onFilter(p)}>
182
- <span>${cfg.icon}</span> ${cfg.name} <span class="text-gray-600">${count}</span>
183
- </button>`
184
- })}
185
- </div>
186
- </nav>`
187
- }
188
-
189
189
  function SkeletonCard() {
190
190
  return html`
191
191
  <div class="w-full max-w-2xl">
@@ -194,7 +194,6 @@
194
194
  <div class="flex items-center gap-2">
195
195
  <div class="w-6 h-6 bg-white/10 rounded skeleton"></div>
196
196
  <div class="w-20 h-4 bg-white/10 rounded skeleton"></div>
197
- <div class="w-16 h-3 bg-white/5 rounded skeleton"></div>
198
197
  </div>
199
198
  <div class="w-16 h-5 bg-white/10 rounded-full skeleton"></div>
200
199
  </div>
@@ -204,28 +203,6 @@
204
203
  <div class="px-5 pb-3 space-y-2">
205
204
  <div class="h-3 bg-white/10 rounded w-full skeleton"></div>
206
205
  <div class="h-3 bg-white/10 rounded w-5/6 skeleton"></div>
207
- <div class="h-3 bg-white/10 rounded w-4/6 skeleton"></div>
208
- </div>
209
- <div class="px-5 pb-3 flex gap-1.5">
210
- <div class="w-16 h-5 bg-white/5 rounded-full skeleton"></div>
211
- <div class="w-20 h-5 bg-white/5 rounded-full skeleton"></div>
212
- <div class="w-14 h-5 bg-white/5 rounded-full skeleton"></div>
213
- </div>
214
- <div class="px-5 pb-3">
215
- <div class="h-1.5 bg-white/5 rounded-full overflow-hidden">
216
- <div class="h-full w-1/2 bg-white/10 rounded-full skeleton"></div>
217
- </div>
218
- </div>
219
- <div class="px-5 pb-4 flex gap-3">
220
- <div class="w-24 h-3 bg-white/5 rounded skeleton"></div>
221
- <div class="w-32 h-3 bg-white/5 rounded skeleton"></div>
222
- </div>
223
- <div class="border-t border-white/5"></div>
224
- <div class="px-5 py-4 flex items-center justify-center gap-3">
225
- <div class="w-24 h-10 bg-white/5 rounded-xl skeleton"></div>
226
- <div class="w-20 h-10 bg-white/5 rounded-xl skeleton"></div>
227
- <div class="w-20 h-10 bg-white/5 rounded-xl skeleton"></div>
228
- <div class="w-24 h-10 bg-white/5 rounded-xl skeleton"></div>
229
206
  </div>
230
207
  </div>
231
208
  </div>`
@@ -261,8 +238,6 @@
261
238
  <span class="text-2xl font-bold text-gray-400">${stats.skipped}</span>
262
239
  <span class="text-gray-500">skipped</span>
263
240
  </div>`}
264
- ${stats.approved === 0 && stats.rejected === 0 && stats.skipped === 0 && html`
265
- <div class="text-gray-400">Nothing to show</div>`}
266
241
  </div>
267
242
  <button onClick=${() => location.reload()} class="mt-4 px-6 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors text-sm font-medium">
268
243
  Refresh Queue
@@ -270,43 +245,33 @@
270
245
  </div>`
271
246
  }
272
247
 
273
- function PlatformBadge({ platform, accounts, accountId }) {
274
- const cfg = PLATFORMS[platform] || { icon: '❓', color: '#888', name: platform }
275
- const connAcct = accounts[platform]
276
- let accountDisplay = null
277
- if (connAcct) {
278
- const displayUser = connAcct.username || connAcct.displayName || connAcct._id
279
- accountDisplay = html`<span class="text-xs text-gray-500">@${displayUser}</span>`
280
- } else if (Object.keys(accounts).length > 0) {
281
- accountDisplay = html`<span class="text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400 border border-red-500/30">⚠ Not connected</span>`
282
- } else if (accountId) {
283
- accountDisplay = html`<span class="text-xs text-gray-500">@${accountId}</span>`
284
- }
285
- return html`
286
- <div class="flex items-center gap-2">
287
- <span class="text-lg">${cfg.icon}</span>
288
- <span class="text-sm font-semibold" style="color: ${cfg.color}">${cfg.name}</span>
289
- ${accountDisplay}
290
- </div>`
291
- }
292
-
293
248
  function ClipTypeBadge({ clipType }) {
294
249
  const labels = { 'video': '📹 Full Video', 'short': '⚡ Short', 'medium-clip': '🎬 Medium Clip' }
295
250
  return html`<span class="text-xs px-2 py-0.5 rounded-full bg-white/10 text-gray-400">${labels[clipType] || clipType}</span>`
296
251
  }
297
252
 
298
- function VideoPlayer({ item }) {
253
+ function VideoPlayer({ group }) {
299
254
  const videoRef = useRef(null)
300
- const prevIdRef = useRef(null)
255
+ const prevKeyRef = useRef(null)
256
+
257
+ const firstItem = group.items[0]
258
+ const hasMedia = group.hasMedia
301
259
 
302
260
  useEffect(() => {
303
- if (item.hasMedia && videoRef.current && prevIdRef.current !== item.id) {
261
+ if (hasMedia && videoRef.current && prevKeyRef.current !== group.groupKey) {
304
262
  videoRef.current.load()
305
- prevIdRef.current = item.id
263
+ prevKeyRef.current = group.groupKey
306
264
  }
307
- }, [item.id, item.hasMedia])
265
+ // Unmute after autoplay starts (browsers require muted for autoplay)
266
+ if (videoRef.current) {
267
+ videoRef.current.muted = false
268
+ }
269
+ // Cleanup: pause video when switching groups or unmounting
270
+ const vid = videoRef.current
271
+ return () => { if (vid) { vid.pause(); vid.muted = true } }
272
+ }, [group.groupKey, hasMedia])
308
273
 
309
- if (!item.hasMedia) {
274
+ if (!hasMedia) {
310
275
  return html`
311
276
  <div class="px-5 pb-3">
312
277
  <div class="rounded-xl overflow-hidden bg-black/40 aspect-video relative">
@@ -323,7 +288,7 @@
323
288
  </div>`
324
289
  }
325
290
 
326
- const mediaUrl = `/media/queue/${encodeURIComponent(item.id)}/media.mp4`
291
+ const mediaUrl = `/media/queue/${encodeURIComponent(firstItem.id)}/media.mp4`
327
292
  return html`
328
293
  <div class="px-5 pb-3">
329
294
  <div class="rounded-xl overflow-hidden bg-black/40 aspect-video relative">
@@ -334,129 +299,142 @@
334
299
  </div>`
335
300
  }
336
301
 
337
- function PostContent({ item, editing, editText, onEditTextChange, onSave, onCancel }) {
338
- const limit = item.metadata.platformCharLimit || 280
339
-
340
- if (editing) {
341
- const len = editText.length
342
- return html`
343
- <div class="px-5 pb-3">
344
- <textarea
345
- class="w-full bg-black/30 border border-white/10 rounded-xl p-3 text-sm text-gray-200 leading-relaxed resize-y focus:outline-none focus:border-blue-500/50 transition-colors"
346
- rows="6"
347
- value=${editText}
348
- onInput=${e => onEditTextChange(e.target.value)}
349
- ref=${el => el && setTimeout(() => el.focus(), 0)}
350
- ></textarea>
351
- <div class="flex items-center justify-between mt-2">
352
- <span class=${`text-xs ${len > limit ? 'text-red-400' : 'text-gray-500'}`}>${len} / ${limit}</span>
353
- <div class="flex gap-2">
354
- <button onClick=${onCancel} class="px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 rounded-lg transition-colors">Cancel</button>
355
- <button onClick=${onSave} class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors font-medium">Save</button>
356
- </div>
357
- </div>
358
- </div>`
359
- }
360
-
302
+ function PlatformCheckboxes({ group, selectedPlatforms, onToggle, accounts }) {
361
303
  return html`
362
304
  <div class="px-5 pb-3">
363
- <div class="text-sm leading-relaxed text-gray-200 whitespace-pre-wrap break-words">${item.postContent || ''}</div>
364
- </div>`
365
- }
366
-
367
- function HashtagRow({ hashtags, color }) {
368
- if (!hashtags || hashtags.length === 0) return null
369
- return html`
370
- <div class="px-5 pb-3 flex flex-wrap gap-1.5">
371
- ${hashtags.map(tag => html`
372
- <span class="inline-block px-2 py-0.5 text-xs rounded-full font-medium"
373
- style="background:${color}20;color:${color};border:1px solid ${color}30">
374
- #${tag.replace(/^#/, '')}
375
- </span>`)}
305
+ <div class="text-xs font-semibold text-gray-400 mb-2">Select platforms to publish:</div>
306
+ <div class="flex flex-wrap gap-2">
307
+ ${group.items.map(item => {
308
+ const platform = normalizePlatform(item.metadata.platform)
309
+ const cfg = PLATFORMS[platform] || { icon: '❓', color: '#888', name: platform }
310
+ const isSelected = selectedPlatforms.includes(item.id)
311
+ const connAcct = accounts[platform]
312
+ const hasAccount = !!connAcct
313
+
314
+ return html`
315
+ <label
316
+ class="flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all ${
317
+ isSelected
318
+ ? 'bg-white/10 border-white/20'
319
+ : 'bg-white/5 border-white/10 hover:bg-white/8'
320
+ }"
321
+ style=${isSelected ? `border-color: ${cfg.color}40` : ''}
322
+ >
323
+ <input
324
+ type="checkbox"
325
+ class="platform-checkbox"
326
+ checked=${isSelected}
327
+ onChange=${() => onToggle(item.id)}
328
+ disabled=${!hasAccount}
329
+ />
330
+ <span class="text-lg">${cfg.icon}</span>
331
+ <span class="text-sm font-medium" style="color: ${cfg.color}">${cfg.name}</span>
332
+ ${!hasAccount && html`
333
+ <span class="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400 border border-red-500/30">⚠</span>
334
+ `}
335
+ </label>
336
+ `
337
+ })}
338
+ </div>
339
+ <div class="mt-2 text-xs text-gray-500">
340
+ ${selectedPlatforms.length} of ${group.items.length} selected
341
+ </div>
376
342
  </div>`
377
343
  }
378
344
 
379
- function CharacterBar({ item }) {
380
- const count = item.metadata.characterCount || (item.postContent || '').length
381
- const limit = item.metadata.platformCharLimit || 280
382
- const pct = Math.min((count / limit) * 100, 100)
383
- const barColor = pct > 95 ? '#ef4444' : pct > 80 ? '#f59e0b' : '#22c55e'
345
+ function PostContentPreview({ group }) {
346
+ const firstItem = group.items[0]
347
+ const content = firstItem.postContent || ''
384
348
  return html`
385
349
  <div class="px-5 pb-3">
386
- <div class="flex items-center justify-between text-xs text-gray-500 mb-1">
387
- <span>Characters</span>
388
- <span>${count} / ${limit}</span>
389
- </div>
390
- <div class="h-1.5 bg-white/5 rounded-full overflow-hidden">
391
- <div class="char-bar h-full rounded-full" style="width: ${pct}%; background-color: ${barColor}"></div>
350
+ <div class="text-xs font-semibold text-gray-400 mb-1">Post preview:</div>
351
+ <div class="text-sm leading-relaxed text-gray-200 whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
352
+ ${truncate(content, 300)}
392
353
  </div>
393
354
  </div>`
394
355
  }
395
356
 
396
- function MetaRow({ item }) {
397
- const sourceVideo = item.metadata.sourceVideo || 'Unknown'
398
- const schedule = item.metadata.suggestedSlot
357
+ function MetaRow({ group }) {
358
+ const sourceVideo = group.sourceVideo || 'Unknown'
399
359
  return html`
400
- <div class="px-5 pb-4 flex items-center justify-between text-xs text-gray-500">
401
- <div class="flex items-center gap-3">
402
- <span title="Source video">📹 ${truncate(sourceVideo, 40)}</span>
403
- ${schedule && html`<span title="Suggested schedule">🕐 ${formatDate(schedule)}</span>`}
404
- </div>
360
+ <div class="px-5 pb-4 text-xs text-gray-500">
361
+ <span title="Source video">📹 ${truncate(sourceVideo, 60)}</span>
405
362
  </div>`
406
363
  }
407
364
 
408
- function ActionButtons({ editing, onReject, onEdit, onSkip, onApprove }) {
365
+ function ActionButtons({ onReject, onSkip, onApprove, selectedCount }) {
366
+ const approveDisabled = selectedCount === 0
409
367
  return html`
410
- <div class=${`px-5 py-4 flex items-center justify-center gap-3 ${editing ? 'opacity-50 pointer-events-none' : ''}`}>
411
- <button onClick=${onReject} class="action-btn flex items-center gap-2 px-5 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-xl text-sm font-medium border border-red-500/20" title="Reject (←)">
412
- <span>❌</span> Reject
368
+ <div class="px-5 py-4 flex items-center justify-center gap-3">
369
+ <button
370
+ onClick=${onReject}
371
+ class="action-btn flex items-center gap-2 px-5 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-xl text-sm font-medium border border-red-500/20"
372
+ title="Reject all (←)"
373
+ >
374
+ <span>❌</span> Reject All
413
375
  </button>
414
- <button onClick=${onEdit} class="action-btn flex items-center gap-2 px-5 py-2.5 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-xl text-sm font-medium border border-yellow-500/20" title="Edit (E)">
415
- <span>✏️</span> Edit
416
- </button>
417
- <button onClick=${onSkip} class="action-btn flex items-center gap-2 px-5 py-2.5 bg-white/5 hover:bg-white/10 text-gray-400 rounded-xl text-sm font-medium border border-white/10" title="Skip (Space)">
376
+ <button
377
+ onClick=${onSkip}
378
+ class="action-btn flex items-center gap-2 px-5 py-2.5 bg-white/5 hover:bg-white/10 text-gray-400 rounded-xl text-sm font-medium border border-white/10"
379
+ title="Skip (Space)"
380
+ >
418
381
  <span>⏭️</span> Skip
419
382
  </button>
420
- <button onClick=${onApprove} class="action-btn flex items-center gap-2 px-5 py-2.5 bg-green-500/10 hover:bg-green-500/20 text-green-400 rounded-xl text-sm font-medium border border-green-500/20" title="Approve (→)">
421
- <span>✅</span> Approve
383
+ <button
384
+ onClick=${onApprove}
385
+ class="action-btn flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium border ${
386
+ approveDisabled
387
+ ? 'bg-gray-500/10 text-gray-500 border-gray-500/20 cursor-not-allowed opacity-50'
388
+ : 'bg-green-500/10 hover:bg-green-500/20 text-green-400 border-green-500/20'
389
+ }"
390
+ title="Approve selected (→)"
391
+ disabled=${approveDisabled}
392
+ >
393
+ <span>✅</span> Approve ${selectedCount > 0 ? `(${selectedCount})` : ''}
422
394
  </button>
423
395
  </div>`
424
396
  }
425
397
 
426
- function PostCard({ item, accounts, editing, editText, onEditTextChange, animClass, onReject, onEdit, onSkip, onApprove, onSaveEdit, onCancelEdit }) {
427
- const platform = normalizePlatform(item.metadata.platform)
428
- const cfg = PLATFORMS[platform] || { icon: '❓', color: '#888', name: platform }
398
+ function GroupedPostCard({ group, accounts, selectedPlatforms, onTogglePlatform, animClass, onReject, onSkip, onApprove }) {
399
+ const selectedCount = selectedPlatforms.length
429
400
  return html`
430
401
  <div class="w-full max-w-2xl" style="max-height: calc(100vh - 160px); display: flex; flex-direction: column;">
431
- <div class="bg-card rounded-2xl shadow-2xl border border-white/5 overflow-hidden flex flex-col ${animClass}" style="max-height: 100%;" key=${item.id}>
432
- <!-- Fixed top: platform badge -->
402
+ <div class="bg-card rounded-2xl shadow-2xl border border-white/5 overflow-hidden flex flex-col ${animClass}" style="max-height: 100%;" key=${group.groupKey}>
403
+ <!-- Fixed top: clip type badge -->
433
404
  <div class="flex-shrink-0 flex items-center justify-between px-5 pt-4 pb-2">
434
- <${PlatformBadge} platform=${platform} accounts=${accounts} accountId=${item.metadata.accountId} />
435
405
  <div class="flex items-center gap-2">
436
- <${ClipTypeBadge} clipType=${item.metadata.clipType} />
406
+ <${ClipTypeBadge} clipType=${group.clipType} />
437
407
  </div>
438
408
  </div>
439
- <!-- Scrollable middle: video + content + meta -->
409
+ <!-- Scrollable middle: video + checkboxes + preview -->
440
410
  <div class="flex-1 overflow-y-auto min-h-0">
441
- <${VideoPlayer} item=${item} />
442
- <${PostContent} item=${item} editing=${editing} editText=${editText}
443
- onEditTextChange=${onEditTextChange} onSave=${onSaveEdit} onCancel=${onCancelEdit} />
444
- <${HashtagRow} hashtags=${item.metadata.hashtags} color=${cfg.color} />
445
- <${CharacterBar} item=${item} />
446
- <${MetaRow} item=${item} />
411
+ <${VideoPlayer} group=${group} />
412
+ <${PlatformCheckboxes}
413
+ group=${group}
414
+ selectedPlatforms=${selectedPlatforms}
415
+ onToggle=${onTogglePlatform}
416
+ accounts=${accounts}
417
+ />
418
+ <${PostContentPreview} group=${group} />
419
+ <${MetaRow} group=${group} />
447
420
  </div>
448
421
  <!-- Fixed bottom: action buttons -->
449
422
  <div class="flex-shrink-0 border-t border-white/5">
450
- <${ActionButtons} editing=${editing} onReject=${onReject} onEdit=${onEdit} onSkip=${onSkip} onApprove=${onApprove} />
423
+ <${ActionButtons}
424
+ onReject=${onReject}
425
+ onSkip=${onSkip}
426
+ onApprove=${onApprove}
427
+ selectedCount=${selectedCount}
428
+ />
451
429
  </div>
452
430
  </div>
453
431
  </div>`
454
432
  }
455
433
 
456
- function Footer({ items, index, stats, allDone }) {
457
- const total = items.length
434
+ function Footer({ groups, index, stats, allDone }) {
435
+ const total = groups.length
458
436
  const current = Math.min(index + 1, total)
459
- const progressText = allDone ? 'Review complete' : (total > 0 ? `Post ${current} of ${total}` : '')
437
+ const progressText = allDone ? 'Review complete' : (total > 0 ? `Group ${current} of ${total}` : '')
460
438
  return html`
461
439
  <footer class="flex-shrink-0 border-t border-white/5 bg-surface/50">
462
440
  <div class="max-w-4xl mx-auto px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
@@ -472,13 +450,11 @@
472
450
 
473
451
  // ── App ──
474
452
  function App() {
475
- const [allItems, setAllItems] = useState(null) // null = loading
453
+ const [allGroups, setAllGroups] = useState(null) // null = loading
476
454
  const [accounts, setAccounts] = useState({})
477
455
  const [profileName, setProfileName] = useState(null)
478
- const [filter, setFilter] = useState('all')
479
456
  const [index, setIndex] = useState(0)
480
- const [editing, setEditing] = useState(false)
481
- const [editText, setEditText] = useState('')
457
+ const [selectedPlatforms, setSelectedPlatforms] = useState([])
482
458
  const [busy, setBusy] = useState(false)
483
459
  const [busyText, setBusyText] = useState('Processing…')
484
460
  const [stats, setStats] = useState({ approved: 0, rejected: 0, skipped: 0 })
@@ -492,7 +468,7 @@
492
468
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
493
469
  const data = await res.json()
494
470
 
495
- setAllItems(data.items || [])
471
+ setAllGroups(data.groups || [])
496
472
 
497
473
  const acctMap = {}
498
474
  for (const acct of (data.accounts || [])) {
@@ -503,26 +479,36 @@
503
479
  if (data.profile && data.profile.name) setProfileName(data.profile.name)
504
480
  } catch (err) {
505
481
  showToast(`Failed to load: ${err.message}`, 'error')
506
- setAllItems([])
482
+ setAllGroups([])
507
483
  }
508
484
  }
509
485
  fetchAll()
510
486
  }, [])
511
487
 
512
- // Compute filtered items
513
- const filteredItems = allItems === null ? [] :
514
- filter === 'all' ? [...allItems] :
515
- allItems.filter(i => normalizePlatform(i.metadata.platform) === filter)
516
-
517
- const currentItem = filteredItems[index] || null
518
- const isLoading = allItems === null
519
- const isEmpty = allItems !== null && allItems.length === 0
520
- const allDone = !isEmpty && !isLoading && (filteredItems.length === 0 || index >= filteredItems.length)
488
+ const currentGroup = allGroups && allGroups[index] || null
489
+ const isLoading = allGroups === null
490
+ const isEmpty = allGroups !== null && allGroups.length === 0
491
+ const allDone = !isEmpty && !isLoading && index >= allGroups.length
521
492
 
522
- const onFilter = useCallback((p) => {
523
- setFilter(p)
524
- setIndex(0)
525
- setEditing(false)
493
+ // Auto-select all platforms with connected accounts when group changes
494
+ useEffect(() => {
495
+ if (currentGroup) {
496
+ const availableIds = currentGroup.items
497
+ .filter(item => {
498
+ const platform = normalizePlatform(item.metadata.platform)
499
+ return !!accounts[platform]
500
+ })
501
+ .map(item => item.id)
502
+ setSelectedPlatforms(availableIds)
503
+ }
504
+ }, [currentGroup, accounts])
505
+
506
+ const togglePlatform = useCallback((itemId) => {
507
+ setSelectedPlatforms(prev =>
508
+ prev.includes(itemId)
509
+ ? prev.filter(id => id !== itemId)
510
+ : [...prev, itemId]
511
+ )
526
512
  }, [])
527
513
 
528
514
  const animateOut = useCallback((cb) => {
@@ -533,112 +519,71 @@
533
519
  }, 250)
534
520
  }, [])
535
521
 
536
- const approvePost = useCallback(async () => {
537
- if (busy || editing || !currentItem) return
538
- setBusy(true)
539
- setBusyText('Scheduling post…')
540
- try {
541
- const res = await fetch(`/api/posts/${currentItem.id}/approve`, { method: 'POST' })
542
- const data = await res.json()
543
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
544
- setStats(s => ({ ...s, approved: s.approved + 1 }))
545
- showToast(`Approved! Scheduled for ${data.scheduledFor ? formatDate(data.scheduledFor) : 'publishing'}`, 'success')
546
- animateOut(() => {
547
- setAllItems(prev => prev.filter(i => i.id !== currentItem.id))
548
- })
549
- } catch (err) {
550
- showToast(`Approve failed: ${err.message}`, 'error')
551
- } finally {
552
- setBusy(false)
553
- }
554
- }, [busy, editing, currentItem, animateOut])
555
-
556
- const rejectPost = useCallback(async () => {
557
- if (busy || editing || !currentItem) return
558
- setBusy(true)
559
- setBusyText('Rejecting…')
560
- try {
561
- const res = await fetch(`/api/posts/${currentItem.id}/reject`, { method: 'POST' })
562
- if (!res.ok) {
563
- const data = await res.json()
564
- throw new Error(data.error || `HTTP ${res.status}`)
565
- }
566
- setStats(s => ({ ...s, rejected: s.rejected + 1 }))
567
- showToast('Post rejected', 'info')
568
- animateOut(() => {
569
- setAllItems(prev => prev.filter(i => i.id !== currentItem.id))
570
- })
571
- } catch (err) {
572
- showToast(`Reject failed: ${err.message}`, 'error')
573
- } finally {
574
- setBusy(false)
575
- }
576
- }, [busy, editing, currentItem, animateOut])
577
-
578
- const skipPost = useCallback(() => {
579
- if (busy || editing) return
580
- setStats(s => ({ ...s, skipped: s.skipped + 1 }))
522
+ const approveGroup = useCallback(async () => {
523
+ if (busy || !currentGroup || selectedPlatforms.length === 0) return
524
+
525
+ const count = selectedPlatforms.length
526
+ const groupKey = currentGroup.groupKey
527
+
528
+ // Fire and forget — send request, don't wait
529
+ fetch('/api/posts/bulk-approve', {
530
+ method: 'POST',
531
+ headers: { 'Content-Type': 'application/json' },
532
+ body: JSON.stringify({ itemIds: selectedPlatforms }),
533
+ }).catch(err => showToast(`Approve failed: ${err.message}`, 'error'))
534
+
535
+ setStats(s => ({ ...s, approved: s.approved + count }))
536
+ showToast(`Scheduling ${count} post(s)…`, 'success')
537
+
538
+ animateOut(() => {
539
+ setAllGroups(prev => prev.filter(g => g.groupKey !== groupKey))
540
+ setSelectedPlatforms([])
541
+ })
542
+ }, [busy, currentGroup, selectedPlatforms, animateOut])
543
+
544
+ const rejectGroup = useCallback(async () => {
545
+ if (busy || !currentGroup) return
546
+
547
+ const allIds = currentGroup.items.map(i => i.id)
548
+ const groupKey = currentGroup.groupKey
549
+
550
+ // Fire and forget send request, don't wait
551
+ fetch('/api/posts/bulk-reject', {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify({ itemIds: allIds }),
555
+ }).catch(err => showToast(`Reject failed: ${err.message}`, 'error'))
556
+
557
+ setStats(s => ({ ...s, rejected: s.rejected + allIds.length }))
558
+ showToast('Group rejected', 'info')
559
+
560
+ animateOut(() => {
561
+ setAllGroups(prev => prev.filter(g => g.groupKey !== groupKey))
562
+ setSelectedPlatforms([])
563
+ })
564
+ }, [busy, currentGroup, animateOut])
565
+
566
+ const skipGroup = useCallback(() => {
567
+ if (busy) return
568
+ const itemCount = currentGroup ? currentGroup.items.length : 0
569
+ setStats(s => ({ ...s, skipped: s.skipped + itemCount }))
581
570
  setIndex(i => i + 1)
582
- }, [busy, editing])
583
-
584
- const startEdit = useCallback(() => {
585
- if (busy || editing || !currentItem) return
586
- setEditText(currentItem.postContent || '')
587
- setEditing(true)
588
- }, [busy, editing, currentItem])
589
-
590
- const cancelEdit = useCallback(() => {
591
- setEditing(false)
592
- }, [])
593
-
594
- const saveEdit = useCallback(async () => {
595
- if (!currentItem) return
596
- setBusy(true)
597
- setBusyText('Saving…')
598
- try {
599
- const res = await fetch(`/api/posts/${currentItem.id}`, {
600
- method: 'PUT',
601
- headers: { 'Content-Type': 'application/json' },
602
- body: JSON.stringify({ postContent: editText }),
603
- })
604
- if (!res.ok) {
605
- const data = await res.json()
606
- throw new Error(data.error || `HTTP ${res.status}`)
607
- }
608
- const updated = await res.json()
609
- const newContent = updated.postContent || editText
610
- setAllItems(prev => prev.map(i =>
611
- i.id === currentItem.id
612
- ? { ...i, postContent: newContent, metadata: { ...i.metadata, characterCount: newContent.length } }
613
- : i
614
- ))
615
- showToast('Post updated', 'success')
616
- setEditing(false)
617
- } catch (err) {
618
- showToast(`Save failed: ${err.message}`, 'error')
619
- } finally {
620
- setBusy(false)
621
- }
622
- }, [currentItem, editText])
571
+ setSelectedPlatforms([])
572
+ }, [busy, currentGroup])
623
573
 
624
574
  // Keyboard shortcuts
625
575
  useEffect(() => {
626
576
  const handler = (e) => {
627
- if (editing) {
628
- if (e.key === 'Escape') cancelEdit()
629
- return
630
- }
631
577
  if (busy) return
632
578
  switch (e.key) {
633
- case 'ArrowLeft': e.preventDefault(); rejectPost(); break
634
- case 'ArrowRight': e.preventDefault(); approvePost(); break
635
- case 'e': case 'E': e.preventDefault(); startEdit(); break
636
- case ' ': e.preventDefault(); skipPost(); break
579
+ case 'ArrowLeft': e.preventDefault(); rejectGroup(); break
580
+ case 'ArrowRight': e.preventDefault(); approveGroup(); break
581
+ case ' ': e.preventDefault(); skipGroup(); break
637
582
  }
638
583
  }
639
584
  document.addEventListener('keydown', handler)
640
585
  return () => document.removeEventListener('keydown', handler)
641
- }, [editing, busy, rejectPost, approvePost, startEdit, skipPost, cancelEdit])
586
+ }, [busy, rejectGroup, approveGroup, skipGroup])
642
587
 
643
588
  // Main content
644
589
  let mainContent
@@ -648,21 +593,17 @@
648
593
  mainContent = html`<${EmptyState} />`
649
594
  } else if (allDone) {
650
595
  mainContent = html`<${SummaryState} stats=${stats} />`
651
- } else if (currentItem) {
596
+ } else if (currentGroup) {
652
597
  mainContent = html`
653
- <${PostCard}
654
- item=${currentItem}
598
+ <${GroupedPostCard}
599
+ group=${currentGroup}
655
600
  accounts=${accounts}
656
- editing=${editing}
657
- editText=${editText}
658
- onEditTextChange=${setEditText}
601
+ selectedPlatforms=${selectedPlatforms}
602
+ onTogglePlatform=${togglePlatform}
659
603
  animClass=${animClass}
660
- onReject=${rejectPost}
661
- onEdit=${startEdit}
662
- onSkip=${skipPost}
663
- onApprove=${approvePost}
664
- onSaveEdit=${saveEdit}
665
- onCancelEdit=${cancelEdit}
604
+ onReject=${rejectGroup}
605
+ onSkip=${skipGroup}
606
+ onApprove=${approveGroup}
666
607
  />`
667
608
  }
668
609
 
@@ -670,15 +611,14 @@
670
611
  <div class="h-full flex flex-col">
671
612
  <${LoadingOverlay} busy=${busy} text=${busyText} />
672
613
  <${Header} profileName=${profileName} />
673
- ${allItems !== null && html`<${PlatformTabs} items=${allItems} filter=${filter} onFilter=${onFilter} />`}
674
614
  <main class="flex-1 overflow-y-auto flex items-start justify-center pt-6 pb-4 px-4">
675
615
  ${mainContent}
676
616
  </main>
677
- <${Footer} items=${filteredItems} index=${index} stats=${stats} allDone=${allDone || isEmpty} />
617
+ <${Footer} groups=${allGroups || []} index=${index} stats=${stats} allDone=${allDone || isEmpty} />
678
618
  </div>`
679
619
  }
680
620
 
681
621
  render(html`<${App} />`, document.getElementById('app'))
682
622
  </script>
683
623
  </body>
684
- </html>
624
+ </html>