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.
- package/dist/index.js +6521 -4003
- package/dist/index.js.map +1 -1
- package/dist/public/index.html +235 -295
- package/package.json +5 -5
- package/assets/features-infographic.png +0 -3
- package/assets/models/ultraface-320.onnx +0 -0
- package/assets/review-ui.png +0 -3
package/dist/public/index.html
CHANGED
|
@@ -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
|
-
/*
|
|
46
|
-
.
|
|
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
|
|
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({
|
|
253
|
+
function VideoPlayer({ group }) {
|
|
299
254
|
const videoRef = useRef(null)
|
|
300
|
-
const
|
|
255
|
+
const prevKeyRef = useRef(null)
|
|
256
|
+
|
|
257
|
+
const firstItem = group.items[0]
|
|
258
|
+
const hasMedia = group.hasMedia
|
|
301
259
|
|
|
302
260
|
useEffect(() => {
|
|
303
|
-
if (
|
|
261
|
+
if (hasMedia && videoRef.current && prevKeyRef.current !== group.groupKey) {
|
|
304
262
|
videoRef.current.load()
|
|
305
|
-
|
|
263
|
+
prevKeyRef.current = group.groupKey
|
|
306
264
|
}
|
|
307
|
-
|
|
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 (!
|
|
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(
|
|
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
|
|
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-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
380
|
-
const
|
|
381
|
-
const
|
|
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="
|
|
387
|
-
|
|
388
|
-
|
|
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({
|
|
397
|
-
const sourceVideo =
|
|
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
|
|
401
|
-
<
|
|
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({
|
|
365
|
+
function ActionButtons({ onReject, onSkip, onApprove, selectedCount }) {
|
|
366
|
+
const approveDisabled = selectedCount === 0
|
|
409
367
|
return html`
|
|
410
|
-
<div class
|
|
411
|
-
<button
|
|
412
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
421
|
-
|
|
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
|
|
427
|
-
const
|
|
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=${
|
|
432
|
-
<!-- Fixed top:
|
|
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=${
|
|
406
|
+
<${ClipTypeBadge} clipType=${group.clipType} />
|
|
437
407
|
</div>
|
|
438
408
|
</div>
|
|
439
|
-
<!-- Scrollable middle: video +
|
|
409
|
+
<!-- Scrollable middle: video + checkboxes + preview -->
|
|
440
410
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
441
|
-
<${VideoPlayer}
|
|
442
|
-
<${
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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}
|
|
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({
|
|
457
|
-
const total =
|
|
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 ? `
|
|
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 [
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
482
|
+
setAllGroups([])
|
|
507
483
|
}
|
|
508
484
|
}
|
|
509
485
|
fetchAll()
|
|
510
486
|
}, [])
|
|
511
487
|
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
537
|
-
if (busy ||
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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();
|
|
634
|
-
case 'ArrowRight': e.preventDefault();
|
|
635
|
-
case '
|
|
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
|
-
}, [
|
|
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 (
|
|
596
|
+
} else if (currentGroup) {
|
|
652
597
|
mainContent = html`
|
|
653
|
-
<${
|
|
654
|
-
|
|
598
|
+
<${GroupedPostCard}
|
|
599
|
+
group=${currentGroup}
|
|
655
600
|
accounts=${accounts}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
onEditTextChange=${setEditText}
|
|
601
|
+
selectedPlatforms=${selectedPlatforms}
|
|
602
|
+
onTogglePlatform=${togglePlatform}
|
|
659
603
|
animClass=${animClass}
|
|
660
|
-
onReject=${
|
|
661
|
-
|
|
662
|
-
|
|
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}
|
|
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>
|