vidpipe 1.2.2 → 1.3.0

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.
Files changed (170) hide show
  1. package/README.md +126 -36
  2. package/assets/features-infographic.png +0 -0
  3. package/assets/models/ultraface-320.onnx +0 -0
  4. package/assets/review-ui.png +0 -0
  5. package/dist/fonts/Montserrat-Bold.ttf +0 -0
  6. package/dist/fonts/Montserrat-Regular.ttf +0 -0
  7. package/dist/fonts/OFL.txt +93 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +6465 -120
  10. package/dist/index.js.map +1 -1
  11. package/dist/models/ultraface-320.onnx +0 -0
  12. package/dist/public/index.html +684 -0
  13. package/package.json +15 -4
  14. package/dist/agents/BaseAgent.d.ts +0 -52
  15. package/dist/agents/BaseAgent.d.ts.map +0 -1
  16. package/dist/agents/BaseAgent.js +0 -102
  17. package/dist/agents/BaseAgent.js.map +0 -1
  18. package/dist/agents/BlogAgent.d.ts +0 -3
  19. package/dist/agents/BlogAgent.d.ts.map +0 -1
  20. package/dist/agents/BlogAgent.js +0 -163
  21. package/dist/agents/BlogAgent.js.map +0 -1
  22. package/dist/agents/ChapterAgent.d.ts +0 -11
  23. package/dist/agents/ChapterAgent.d.ts.map +0 -1
  24. package/dist/agents/ChapterAgent.js +0 -191
  25. package/dist/agents/ChapterAgent.js.map +0 -1
  26. package/dist/agents/MediumVideoAgent.d.ts +0 -3
  27. package/dist/agents/MediumVideoAgent.d.ts.map +0 -1
  28. package/dist/agents/MediumVideoAgent.js +0 -219
  29. package/dist/agents/MediumVideoAgent.js.map +0 -1
  30. package/dist/agents/ShortsAgent.d.ts +0 -3
  31. package/dist/agents/ShortsAgent.d.ts.map +0 -1
  32. package/dist/agents/ShortsAgent.js +0 -243
  33. package/dist/agents/ShortsAgent.js.map +0 -1
  34. package/dist/agents/SilenceRemovalAgent.d.ts +0 -9
  35. package/dist/agents/SilenceRemovalAgent.d.ts.map +0 -1
  36. package/dist/agents/SilenceRemovalAgent.js +0 -209
  37. package/dist/agents/SilenceRemovalAgent.js.map +0 -1
  38. package/dist/agents/SocialMediaAgent.d.ts +0 -4
  39. package/dist/agents/SocialMediaAgent.d.ts.map +0 -1
  40. package/dist/agents/SocialMediaAgent.js +0 -248
  41. package/dist/agents/SocialMediaAgent.js.map +0 -1
  42. package/dist/agents/SummaryAgent.d.ts +0 -11
  43. package/dist/agents/SummaryAgent.d.ts.map +0 -1
  44. package/dist/agents/SummaryAgent.js +0 -333
  45. package/dist/agents/SummaryAgent.js.map +0 -1
  46. package/dist/commands/doctor.d.ts +0 -4
  47. package/dist/commands/doctor.d.ts.map +0 -1
  48. package/dist/commands/doctor.js +0 -230
  49. package/dist/commands/doctor.js.map +0 -1
  50. package/dist/config/brand.d.ts +0 -29
  51. package/dist/config/brand.d.ts.map +0 -1
  52. package/dist/config/brand.js +0 -83
  53. package/dist/config/brand.js.map +0 -1
  54. package/dist/config/environment.d.ts +0 -39
  55. package/dist/config/environment.d.ts.map +0 -1
  56. package/dist/config/environment.js +0 -47
  57. package/dist/config/environment.js.map +0 -1
  58. package/dist/config/ffmpegResolver.d.ts +0 -3
  59. package/dist/config/ffmpegResolver.d.ts.map +0 -1
  60. package/dist/config/ffmpegResolver.js +0 -37
  61. package/dist/config/ffmpegResolver.js.map +0 -1
  62. package/dist/config/logger.d.ts +0 -5
  63. package/dist/config/logger.d.ts.map +0 -1
  64. package/dist/config/logger.js +0 -13
  65. package/dist/config/logger.js.map +0 -1
  66. package/dist/config/pricing.d.ts +0 -34
  67. package/dist/config/pricing.d.ts.map +0 -1
  68. package/dist/config/pricing.js +0 -71
  69. package/dist/config/pricing.js.map +0 -1
  70. package/dist/index.d.ts.map +0 -1
  71. package/dist/pipeline.d.ts +0 -57
  72. package/dist/pipeline.d.ts.map +0 -1
  73. package/dist/pipeline.js +0 -324
  74. package/dist/pipeline.js.map +0 -1
  75. package/dist/providers/ClaudeProvider.d.ts +0 -14
  76. package/dist/providers/ClaudeProvider.d.ts.map +0 -1
  77. package/dist/providers/ClaudeProvider.js +0 -182
  78. package/dist/providers/ClaudeProvider.js.map +0 -1
  79. package/dist/providers/CopilotProvider.d.ts +0 -17
  80. package/dist/providers/CopilotProvider.d.ts.map +0 -1
  81. package/dist/providers/CopilotProvider.js +0 -149
  82. package/dist/providers/CopilotProvider.js.map +0 -1
  83. package/dist/providers/OpenAIProvider.d.ts +0 -14
  84. package/dist/providers/OpenAIProvider.d.ts.map +0 -1
  85. package/dist/providers/OpenAIProvider.js +0 -175
  86. package/dist/providers/OpenAIProvider.js.map +0 -1
  87. package/dist/providers/index.d.ts +0 -18
  88. package/dist/providers/index.d.ts.map +0 -1
  89. package/dist/providers/index.js +0 -61
  90. package/dist/providers/index.js.map +0 -1
  91. package/dist/providers/types.d.ts +0 -112
  92. package/dist/providers/types.d.ts.map +0 -1
  93. package/dist/providers/types.js +0 -8
  94. package/dist/providers/types.js.map +0 -1
  95. package/dist/services/captionGeneration.d.ts +0 -7
  96. package/dist/services/captionGeneration.d.ts.map +0 -1
  97. package/dist/services/captionGeneration.js +0 -29
  98. package/dist/services/captionGeneration.js.map +0 -1
  99. package/dist/services/costTracker.d.ts +0 -63
  100. package/dist/services/costTracker.d.ts.map +0 -1
  101. package/dist/services/costTracker.js +0 -137
  102. package/dist/services/costTracker.js.map +0 -1
  103. package/dist/services/fileWatcher.d.ts +0 -19
  104. package/dist/services/fileWatcher.d.ts.map +0 -1
  105. package/dist/services/fileWatcher.js +0 -120
  106. package/dist/services/fileWatcher.js.map +0 -1
  107. package/dist/services/gitOperations.d.ts +0 -3
  108. package/dist/services/gitOperations.d.ts.map +0 -1
  109. package/dist/services/gitOperations.js +0 -43
  110. package/dist/services/gitOperations.js.map +0 -1
  111. package/dist/services/socialPosting.d.ts +0 -38
  112. package/dist/services/socialPosting.d.ts.map +0 -1
  113. package/dist/services/socialPosting.js +0 -102
  114. package/dist/services/socialPosting.js.map +0 -1
  115. package/dist/services/transcription.d.ts +0 -3
  116. package/dist/services/transcription.d.ts.map +0 -1
  117. package/dist/services/transcription.js +0 -100
  118. package/dist/services/transcription.js.map +0 -1
  119. package/dist/services/videoIngestion.d.ts +0 -3
  120. package/dist/services/videoIngestion.d.ts.map +0 -1
  121. package/dist/services/videoIngestion.js +0 -104
  122. package/dist/services/videoIngestion.js.map +0 -1
  123. package/dist/tools/captions/captionGenerator.d.ts +0 -84
  124. package/dist/tools/captions/captionGenerator.d.ts.map +0 -1
  125. package/dist/tools/captions/captionGenerator.js +0 -390
  126. package/dist/tools/captions/captionGenerator.js.map +0 -1
  127. package/dist/tools/ffmpeg/aspectRatio.d.ts +0 -101
  128. package/dist/tools/ffmpeg/aspectRatio.d.ts.map +0 -1
  129. package/dist/tools/ffmpeg/aspectRatio.js +0 -339
  130. package/dist/tools/ffmpeg/aspectRatio.js.map +0 -1
  131. package/dist/tools/ffmpeg/audioExtraction.d.ts +0 -16
  132. package/dist/tools/ffmpeg/audioExtraction.d.ts.map +0 -1
  133. package/dist/tools/ffmpeg/audioExtraction.js +0 -87
  134. package/dist/tools/ffmpeg/audioExtraction.js.map +0 -1
  135. package/dist/tools/ffmpeg/captionBurning.d.ts +0 -8
  136. package/dist/tools/ffmpeg/captionBurning.d.ts.map +0 -1
  137. package/dist/tools/ffmpeg/captionBurning.js +0 -72
  138. package/dist/tools/ffmpeg/captionBurning.js.map +0 -1
  139. package/dist/tools/ffmpeg/clipExtraction.d.ts +0 -38
  140. package/dist/tools/ffmpeg/clipExtraction.d.ts.map +0 -1
  141. package/dist/tools/ffmpeg/clipExtraction.js +0 -215
  142. package/dist/tools/ffmpeg/clipExtraction.js.map +0 -1
  143. package/dist/tools/ffmpeg/faceDetection.d.ts +0 -127
  144. package/dist/tools/ffmpeg/faceDetection.d.ts.map +0 -1
  145. package/dist/tools/ffmpeg/faceDetection.js +0 -501
  146. package/dist/tools/ffmpeg/faceDetection.js.map +0 -1
  147. package/dist/tools/ffmpeg/frameCapture.d.ts +0 -10
  148. package/dist/tools/ffmpeg/frameCapture.d.ts.map +0 -1
  149. package/dist/tools/ffmpeg/frameCapture.js +0 -49
  150. package/dist/tools/ffmpeg/frameCapture.js.map +0 -1
  151. package/dist/tools/ffmpeg/silenceDetection.d.ts +0 -10
  152. package/dist/tools/ffmpeg/silenceDetection.d.ts.map +0 -1
  153. package/dist/tools/ffmpeg/silenceDetection.js +0 -56
  154. package/dist/tools/ffmpeg/silenceDetection.js.map +0 -1
  155. package/dist/tools/ffmpeg/singlePassEdit.d.ts +0 -25
  156. package/dist/tools/ffmpeg/singlePassEdit.d.ts.map +0 -1
  157. package/dist/tools/ffmpeg/singlePassEdit.js +0 -124
  158. package/dist/tools/ffmpeg/singlePassEdit.js.map +0 -1
  159. package/dist/tools/search/exaClient.d.ts +0 -8
  160. package/dist/tools/search/exaClient.d.ts.map +0 -1
  161. package/dist/tools/search/exaClient.js +0 -38
  162. package/dist/tools/search/exaClient.js.map +0 -1
  163. package/dist/tools/whisper/whisperClient.d.ts +0 -3
  164. package/dist/tools/whisper/whisperClient.d.ts.map +0 -1
  165. package/dist/tools/whisper/whisperClient.js +0 -77
  166. package/dist/tools/whisper/whisperClient.js.map +0 -1
  167. package/dist/types/index.d.ts +0 -305
  168. package/dist/types/index.d.ts.map +0 -1
  169. package/dist/types/index.js +0 -44
  170. package/dist/types/index.js.map +0 -1
@@ -0,0 +1,684 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>vidpipe — Review Queue</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>
9
+ tailwind.config = {
10
+ darkMode: 'class',
11
+ theme: {
12
+ extend: {
13
+ colors: {
14
+ surface: '#1a1a2e',
15
+ card: '#16213e',
16
+ cardHover: '#1a2744',
17
+ accent: '#0f3460',
18
+ }
19
+ }
20
+ }
21
+ }
22
+ </script>
23
+ <style>
24
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
25
+ @keyframes fadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-12px); } }
26
+ @keyframes slideInRight { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
27
+ @keyframes toastIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
28
+ @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateY(-20px); } }
29
+ @keyframes spin { to { transform: rotate(360deg); } }
30
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
31
+
32
+ .card-enter { animation: fadeIn 0.3s ease-out forwards; }
33
+ .card-exit { animation: fadeOut 0.25s ease-in forwards; }
34
+ .slide-in { animation: slideInRight 0.3s ease-out forwards; }
35
+ .toast-enter { animation: toastIn 0.3s ease-out forwards; }
36
+ .toast-exit { animation: toastOut 0.3s ease-in forwards; }
37
+ .spinner { animation: spin 0.7s linear infinite; }
38
+ .skeleton { animation: pulse 1.5s ease-in-out infinite; }
39
+
40
+ /* Custom scrollbar */
41
+ ::-webkit-scrollbar { width: 6px; }
42
+ ::-webkit-scrollbar-track { background: #1a1a2e; }
43
+ ::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
44
+
45
+ /* Character count bar */
46
+ .char-bar { transition: width 0.3s ease, background-color 0.3s ease; }
47
+
48
+ /* Action button hover effects */
49
+ .action-btn { transition: all 0.15s ease; }
50
+ .action-btn:hover { transform: translateY(-2px); }
51
+ .action-btn:active { transform: translateY(0); }
52
+
53
+ /* Focus ring for keyboard navigation */
54
+ .action-btn:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
55
+
56
+ /* Platform filter tab */
57
+ .platform-tab { transition: all 0.2s ease; }
58
+ .platform-tab.active { border-bottom: 2px solid currentColor; }
59
+
60
+ body { background: #0f0f23; }
61
+ </style>
62
+ </head>
63
+ <body class="h-full text-gray-100 overflow-hidden">
64
+ <div id="app" role="main" aria-label="Social media post review application"></div>
65
+
66
+ <script type="module">
67
+ import { h, render } from 'https://esm.sh/preact@10'
68
+ import { useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact@10/hooks'
69
+ import htm from 'https://esm.sh/htm@3'
70
+ const html = htm.bind(h)
71
+
72
+ // ── Platform config ──
73
+ const PLATFORMS = {
74
+ tiktok: { icon: '🎵', color: '#00f2ea', name: 'TikTok' },
75
+ youtube: { icon: '▶️', color: '#ff0000', name: 'YouTube' },
76
+ instagram: { icon: '📸', color: '#e1306c', name: 'Instagram' },
77
+ linkedin: { icon: '💼', color: '#0077b5', name: 'LinkedIn' },
78
+ twitter: { icon: '🐦', color: '#1da1f2', name: 'X/Twitter' },
79
+ }
80
+
81
+ // ── Helpers ──
82
+ function normalizePlatform(raw) {
83
+ if (!raw) return 'unknown'
84
+ const lower = raw.toLowerCase()
85
+ if (lower.includes('tiktok') || lower === 'tik-tok') return 'tiktok'
86
+ if (lower.includes('youtube') || lower === 'yt') return 'youtube'
87
+ if (lower.includes('instagram') || lower === 'ig') return 'instagram'
88
+ if (lower.includes('linkedin')) return 'linkedin'
89
+ if (lower.includes('twitter') || lower === 'x') return 'twitter'
90
+ return lower
91
+ }
92
+
93
+ function truncate(str, max) {
94
+ if (!str) return ''
95
+ return str.length > max ? str.slice(0, max) + '…' : str
96
+ }
97
+
98
+ function formatDate(iso) {
99
+ try {
100
+ const d = new Date(iso)
101
+ return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }) +
102
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
103
+ } catch { return iso }
104
+ }
105
+
106
+ // ── Toast Container (imperative, lives outside Preact tree) ──
107
+ const toastContainer = document.createElement('div')
108
+ toastContainer.className = 'fixed top-4 right-4 z-50 flex flex-col gap-2'
109
+ document.body.appendChild(toastContainer)
110
+
111
+ function showToast(message, type = 'info') {
112
+ const colors = {
113
+ success: 'bg-green-500/20 border-green-500/30 text-green-300',
114
+ error: 'bg-red-500/20 border-red-500/30 text-red-300',
115
+ info: 'bg-blue-500/20 border-blue-500/30 text-blue-300',
116
+ }
117
+ const icons = { success: '✅', error: '⚠️', info: 'ℹ️' }
118
+ const toast = document.createElement('div')
119
+ toast.className = `toast-enter flex items-center gap-2 px-4 py-2.5 rounded-xl border backdrop-blur-sm text-sm shadow-lg ${colors[type] || colors.info}`
120
+ const iconSpan = document.createElement('span')
121
+ iconSpan.textContent = icons[type] || ''
122
+ const msgSpan = document.createElement('span')
123
+ msgSpan.textContent = message
124
+ toast.appendChild(iconSpan)
125
+ toast.appendChild(msgSpan)
126
+ toastContainer.appendChild(toast)
127
+ setTimeout(() => {
128
+ toast.classList.remove('toast-enter')
129
+ toast.classList.add('toast-exit')
130
+ setTimeout(() => toast.remove(), 300)
131
+ }, 4000)
132
+ }
133
+
134
+ // ── Components ──
135
+
136
+ function LoadingOverlay({ busy, text }) {
137
+ if (!busy) return null
138
+ return html`
139
+ <div class="fixed inset-0 z-40 bg-black/50 flex items-center justify-center">
140
+ <div class="bg-card rounded-2xl p-8 flex flex-col items-center gap-3 shadow-2xl">
141
+ <div class="w-8 h-8 border-3 border-blue-400 border-t-transparent rounded-full spinner" style="border-width:3px"></div>
142
+ <span class="text-sm text-gray-400">${text}</span>
143
+ </div>
144
+ </div>`
145
+ }
146
+
147
+ function Header({ profileName }) {
148
+ const subtitle = profileName ? `Review Queue — ${profileName}` : 'Review Queue'
149
+ return html`
150
+ <header class="flex-shrink-0 border-b border-white/10 bg-surface/80 backdrop-blur-sm">
151
+ <div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
152
+ <div class="flex items-center gap-3">
153
+ <span class="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">vidpipe</span>
154
+ <span class="text-gray-500 text-sm">${subtitle}</span>
155
+ </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">
157
+ <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">←</kbd> Reject
158
+ <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
+ <kbd class="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">Space</kbd> Skip
161
+ </div>
162
+ </div>
163
+ </header>`
164
+ }
165
+
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
+ function SkeletonCard() {
190
+ return html`
191
+ <div class="w-full max-w-2xl">
192
+ <div class="bg-card rounded-2xl shadow-2xl border border-white/5 overflow-hidden">
193
+ <div class="flex items-center justify-between px-5 pt-4 pb-2">
194
+ <div class="flex items-center gap-2">
195
+ <div class="w-6 h-6 bg-white/10 rounded skeleton"></div>
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
+ </div>
199
+ <div class="w-16 h-5 bg-white/10 rounded-full skeleton"></div>
200
+ </div>
201
+ <div class="px-5 pb-3">
202
+ <div class="rounded-xl bg-white/5 aspect-video skeleton"></div>
203
+ </div>
204
+ <div class="px-5 pb-3 space-y-2">
205
+ <div class="h-3 bg-white/10 rounded w-full skeleton"></div>
206
+ <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
+ </div>
230
+ </div>
231
+ </div>`
232
+ }
233
+
234
+ function EmptyState() {
235
+ return html`
236
+ <div class="flex flex-col items-center justify-center gap-4 text-center mt-20">
237
+ <div class="text-6xl">🎬</div>
238
+ <h2 class="text-xl font-semibold text-gray-300">No posts pending review</h2>
239
+ <p class="text-gray-500">Run your pipeline first!</p>
240
+ </div>`
241
+ }
242
+
243
+ function SummaryState({ stats }) {
244
+ return html`
245
+ <div class="flex flex-col items-center justify-center gap-6 text-center mt-20">
246
+ <div class="text-6xl">🎉</div>
247
+ <h2 class="text-2xl font-bold text-gray-200">All caught up!</h2>
248
+ <div class="flex gap-6 text-sm">
249
+ ${stats.approved > 0 && html`
250
+ <div class="flex flex-col items-center px-4 py-2 bg-green-500/10 rounded-xl border border-green-500/20">
251
+ <span class="text-2xl font-bold text-green-400">${stats.approved}</span>
252
+ <span class="text-green-400/70">approved</span>
253
+ </div>`}
254
+ ${stats.rejected > 0 && html`
255
+ <div class="flex flex-col items-center px-4 py-2 bg-red-500/10 rounded-xl border border-red-500/20">
256
+ <span class="text-2xl font-bold text-red-400">${stats.rejected}</span>
257
+ <span class="text-red-400/70">rejected</span>
258
+ </div>`}
259
+ ${stats.skipped > 0 && html`
260
+ <div class="flex flex-col items-center px-4 py-2 bg-white/5 rounded-xl border border-white/10">
261
+ <span class="text-2xl font-bold text-gray-400">${stats.skipped}</span>
262
+ <span class="text-gray-500">skipped</span>
263
+ </div>`}
264
+ ${stats.approved === 0 && stats.rejected === 0 && stats.skipped === 0 && html`
265
+ <div class="text-gray-400">Nothing to show</div>`}
266
+ </div>
267
+ <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
+ Refresh Queue
269
+ </button>
270
+ </div>`
271
+ }
272
+
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
+ function ClipTypeBadge({ clipType }) {
294
+ const labels = { 'video': '📹 Full Video', 'short': '⚡ Short', 'medium-clip': '🎬 Medium Clip' }
295
+ return html`<span class="text-xs px-2 py-0.5 rounded-full bg-white/10 text-gray-400">${labels[clipType] || clipType}</span>`
296
+ }
297
+
298
+ function VideoPlayer({ item }) {
299
+ const videoRef = useRef(null)
300
+ const prevIdRef = useRef(null)
301
+
302
+ useEffect(() => {
303
+ if (item.hasMedia && videoRef.current && prevIdRef.current !== item.id) {
304
+ videoRef.current.load()
305
+ prevIdRef.current = item.id
306
+ }
307
+ }, [item.id, item.hasMedia])
308
+
309
+ if (!item.hasMedia) {
310
+ return html`
311
+ <div class="px-5 pb-3">
312
+ <div class="rounded-xl overflow-hidden bg-black/40 aspect-video relative">
313
+ <div class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-purple-500/10 to-blue-500/10">
314
+ <div class="text-center">
315
+ <div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 border border-white/10 mb-2">
316
+ <span class="text-lg">📝</span>
317
+ <span class="text-sm font-semibold text-gray-300">Text Only</span>
318
+ </div>
319
+ <div class="text-xs text-gray-500">No media attachment needed</div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>`
324
+ }
325
+
326
+ const mediaUrl = `/media/queue/${encodeURIComponent(item.id)}/media.mp4`
327
+ return html`
328
+ <div class="px-5 pb-3">
329
+ <div class="rounded-xl overflow-hidden bg-black/40 aspect-video relative">
330
+ <video ref=${videoRef} class="w-full h-full object-contain" controls autoplay muted loop playsinline>
331
+ <source src=${mediaUrl} type="video/mp4" />
332
+ </video>
333
+ </div>
334
+ </div>`
335
+ }
336
+
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
+
361
+ return html`
362
+ <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>`)}
376
+ </div>`
377
+ }
378
+
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'
384
+ return html`
385
+ <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>
392
+ </div>
393
+ </div>`
394
+ }
395
+
396
+ function MetaRow({ item }) {
397
+ const sourceVideo = item.metadata.sourceVideo || 'Unknown'
398
+ const schedule = item.metadata.suggestedSlot
399
+ 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>
405
+ </div>`
406
+ }
407
+
408
+ function ActionButtons({ editing, onReject, onEdit, onSkip, onApprove }) {
409
+ 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
413
+ </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)">
418
+ <span>⏭️</span> Skip
419
+ </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
422
+ </button>
423
+ </div>`
424
+ }
425
+
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 }
429
+ return html`
430
+ <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 -->
433
+ <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
+ <div class="flex items-center gap-2">
436
+ <${ClipTypeBadge} clipType=${item.metadata.clipType} />
437
+ </div>
438
+ </div>
439
+ <!-- Scrollable middle: video + content + meta -->
440
+ <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} />
447
+ </div>
448
+ <!-- Fixed bottom: action buttons -->
449
+ <div class="flex-shrink-0 border-t border-white/5">
450
+ <${ActionButtons} editing=${editing} onReject=${onReject} onEdit=${onEdit} onSkip=${onSkip} onApprove=${onApprove} />
451
+ </div>
452
+ </div>
453
+ </div>`
454
+ }
455
+
456
+ function Footer({ items, index, stats, allDone }) {
457
+ const total = items.length
458
+ const current = Math.min(index + 1, total)
459
+ const progressText = allDone ? 'Review complete' : (total > 0 ? `Post ${current} of ${total}` : '')
460
+ return html`
461
+ <footer class="flex-shrink-0 border-t border-white/5 bg-surface/50">
462
+ <div class="max-w-4xl mx-auto px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
463
+ <span>${progressText}</span>
464
+ <div class="flex items-center gap-3">
465
+ ${stats.approved > 0 && html`<span class="text-green-400/70">✅ ${stats.approved}</span>`}
466
+ ${stats.rejected > 0 && html`<span class="text-red-400/70">❌ ${stats.rejected}</span>`}
467
+ ${stats.skipped > 0 && html`<span class="text-gray-500">⏭️ ${stats.skipped}</span>`}
468
+ </div>
469
+ </div>
470
+ </footer>`
471
+ }
472
+
473
+ // ── App ──
474
+ function App() {
475
+ const [allItems, setAllItems] = useState(null) // null = loading
476
+ const [accounts, setAccounts] = useState({})
477
+ const [profileName, setProfileName] = useState(null)
478
+ const [filter, setFilter] = useState('all')
479
+ const [index, setIndex] = useState(0)
480
+ const [editing, setEditing] = useState(false)
481
+ const [editText, setEditText] = useState('')
482
+ const [busy, setBusy] = useState(false)
483
+ const [busyText, setBusyText] = useState('Processing…')
484
+ const [stats, setStats] = useState({ approved: 0, rejected: 0, skipped: 0 })
485
+ const [animClass, setAnimClass] = useState('card-enter')
486
+
487
+ // Parallel data loading via combined init endpoint
488
+ useEffect(() => {
489
+ const fetchAll = async () => {
490
+ try {
491
+ const res = await fetch('/api/init')
492
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
493
+ const data = await res.json()
494
+
495
+ setAllItems(data.items || [])
496
+
497
+ const acctMap = {}
498
+ for (const acct of (data.accounts || [])) {
499
+ if (acct.platform) acctMap[acct.platform] = acct
500
+ }
501
+ setAccounts(acctMap)
502
+
503
+ if (data.profile && data.profile.name) setProfileName(data.profile.name)
504
+ } catch (err) {
505
+ showToast(`Failed to load: ${err.message}`, 'error')
506
+ setAllItems([])
507
+ }
508
+ }
509
+ fetchAll()
510
+ }, [])
511
+
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)
521
+
522
+ const onFilter = useCallback((p) => {
523
+ setFilter(p)
524
+ setIndex(0)
525
+ setEditing(false)
526
+ }, [])
527
+
528
+ const animateOut = useCallback((cb) => {
529
+ setAnimClass('card-exit')
530
+ setTimeout(() => {
531
+ cb()
532
+ setAnimClass('card-enter')
533
+ }, 250)
534
+ }, [])
535
+
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 }))
581
+ 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])
623
+
624
+ // Keyboard shortcuts
625
+ useEffect(() => {
626
+ const handler = (e) => {
627
+ if (editing) {
628
+ if (e.key === 'Escape') cancelEdit()
629
+ return
630
+ }
631
+ if (busy) return
632
+ 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
637
+ }
638
+ }
639
+ document.addEventListener('keydown', handler)
640
+ return () => document.removeEventListener('keydown', handler)
641
+ }, [editing, busy, rejectPost, approvePost, startEdit, skipPost, cancelEdit])
642
+
643
+ // Main content
644
+ let mainContent
645
+ if (isLoading) {
646
+ mainContent = html`<${SkeletonCard} />`
647
+ } else if (isEmpty) {
648
+ mainContent = html`<${EmptyState} />`
649
+ } else if (allDone) {
650
+ mainContent = html`<${SummaryState} stats=${stats} />`
651
+ } else if (currentItem) {
652
+ mainContent = html`
653
+ <${PostCard}
654
+ item=${currentItem}
655
+ accounts=${accounts}
656
+ editing=${editing}
657
+ editText=${editText}
658
+ onEditTextChange=${setEditText}
659
+ animClass=${animClass}
660
+ onReject=${rejectPost}
661
+ onEdit=${startEdit}
662
+ onSkip=${skipPost}
663
+ onApprove=${approvePost}
664
+ onSaveEdit=${saveEdit}
665
+ onCancelEdit=${cancelEdit}
666
+ />`
667
+ }
668
+
669
+ return html`
670
+ <div class="h-full flex flex-col">
671
+ <${LoadingOverlay} busy=${busy} text=${busyText} />
672
+ <${Header} profileName=${profileName} />
673
+ ${allItems !== null && html`<${PlatformTabs} items=${allItems} filter=${filter} onFilter=${onFilter} />`}
674
+ <main class="flex-1 overflow-y-auto flex items-start justify-center pt-6 pb-4 px-4">
675
+ ${mainContent}
676
+ </main>
677
+ <${Footer} items=${filteredItems} index=${index} stats=${stats} allDone=${allDone || isEmpty} />
678
+ </div>`
679
+ }
680
+
681
+ render(html`<${App} />`, document.getElementById('app'))
682
+ </script>
683
+ </body>
684
+ </html>