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.
- package/README.md +126 -36
- package/assets/features-infographic.png +0 -0
- package/assets/models/ultraface-320.onnx +0 -0
- package/assets/review-ui.png +0 -0
- package/dist/fonts/Montserrat-Bold.ttf +0 -0
- package/dist/fonts/Montserrat-Regular.ttf +0 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6465 -120
- package/dist/index.js.map +1 -1
- package/dist/models/ultraface-320.onnx +0 -0
- package/dist/public/index.html +684 -0
- package/package.json +15 -4
- package/dist/agents/BaseAgent.d.ts +0 -52
- package/dist/agents/BaseAgent.d.ts.map +0 -1
- package/dist/agents/BaseAgent.js +0 -102
- package/dist/agents/BaseAgent.js.map +0 -1
- package/dist/agents/BlogAgent.d.ts +0 -3
- package/dist/agents/BlogAgent.d.ts.map +0 -1
- package/dist/agents/BlogAgent.js +0 -163
- package/dist/agents/BlogAgent.js.map +0 -1
- package/dist/agents/ChapterAgent.d.ts +0 -11
- package/dist/agents/ChapterAgent.d.ts.map +0 -1
- package/dist/agents/ChapterAgent.js +0 -191
- package/dist/agents/ChapterAgent.js.map +0 -1
- package/dist/agents/MediumVideoAgent.d.ts +0 -3
- package/dist/agents/MediumVideoAgent.d.ts.map +0 -1
- package/dist/agents/MediumVideoAgent.js +0 -219
- package/dist/agents/MediumVideoAgent.js.map +0 -1
- package/dist/agents/ShortsAgent.d.ts +0 -3
- package/dist/agents/ShortsAgent.d.ts.map +0 -1
- package/dist/agents/ShortsAgent.js +0 -243
- package/dist/agents/ShortsAgent.js.map +0 -1
- package/dist/agents/SilenceRemovalAgent.d.ts +0 -9
- package/dist/agents/SilenceRemovalAgent.d.ts.map +0 -1
- package/dist/agents/SilenceRemovalAgent.js +0 -209
- package/dist/agents/SilenceRemovalAgent.js.map +0 -1
- package/dist/agents/SocialMediaAgent.d.ts +0 -4
- package/dist/agents/SocialMediaAgent.d.ts.map +0 -1
- package/dist/agents/SocialMediaAgent.js +0 -248
- package/dist/agents/SocialMediaAgent.js.map +0 -1
- package/dist/agents/SummaryAgent.d.ts +0 -11
- package/dist/agents/SummaryAgent.d.ts.map +0 -1
- package/dist/agents/SummaryAgent.js +0 -333
- package/dist/agents/SummaryAgent.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -4
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -230
- package/dist/commands/doctor.js.map +0 -1
- package/dist/config/brand.d.ts +0 -29
- package/dist/config/brand.d.ts.map +0 -1
- package/dist/config/brand.js +0 -83
- package/dist/config/brand.js.map +0 -1
- package/dist/config/environment.d.ts +0 -39
- package/dist/config/environment.d.ts.map +0 -1
- package/dist/config/environment.js +0 -47
- package/dist/config/environment.js.map +0 -1
- package/dist/config/ffmpegResolver.d.ts +0 -3
- package/dist/config/ffmpegResolver.d.ts.map +0 -1
- package/dist/config/ffmpegResolver.js +0 -37
- package/dist/config/ffmpegResolver.js.map +0 -1
- package/dist/config/logger.d.ts +0 -5
- package/dist/config/logger.d.ts.map +0 -1
- package/dist/config/logger.js +0 -13
- package/dist/config/logger.js.map +0 -1
- package/dist/config/pricing.d.ts +0 -34
- package/dist/config/pricing.d.ts.map +0 -1
- package/dist/config/pricing.js +0 -71
- package/dist/config/pricing.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/pipeline.d.ts +0 -57
- package/dist/pipeline.d.ts.map +0 -1
- package/dist/pipeline.js +0 -324
- package/dist/pipeline.js.map +0 -1
- package/dist/providers/ClaudeProvider.d.ts +0 -14
- package/dist/providers/ClaudeProvider.d.ts.map +0 -1
- package/dist/providers/ClaudeProvider.js +0 -182
- package/dist/providers/ClaudeProvider.js.map +0 -1
- package/dist/providers/CopilotProvider.d.ts +0 -17
- package/dist/providers/CopilotProvider.d.ts.map +0 -1
- package/dist/providers/CopilotProvider.js +0 -149
- package/dist/providers/CopilotProvider.js.map +0 -1
- package/dist/providers/OpenAIProvider.d.ts +0 -14
- package/dist/providers/OpenAIProvider.d.ts.map +0 -1
- package/dist/providers/OpenAIProvider.js +0 -175
- package/dist/providers/OpenAIProvider.js.map +0 -1
- package/dist/providers/index.d.ts +0 -18
- package/dist/providers/index.d.ts.map +0 -1
- package/dist/providers/index.js +0 -61
- package/dist/providers/index.js.map +0 -1
- package/dist/providers/types.d.ts +0 -112
- package/dist/providers/types.d.ts.map +0 -1
- package/dist/providers/types.js +0 -8
- package/dist/providers/types.js.map +0 -1
- package/dist/services/captionGeneration.d.ts +0 -7
- package/dist/services/captionGeneration.d.ts.map +0 -1
- package/dist/services/captionGeneration.js +0 -29
- package/dist/services/captionGeneration.js.map +0 -1
- package/dist/services/costTracker.d.ts +0 -63
- package/dist/services/costTracker.d.ts.map +0 -1
- package/dist/services/costTracker.js +0 -137
- package/dist/services/costTracker.js.map +0 -1
- package/dist/services/fileWatcher.d.ts +0 -19
- package/dist/services/fileWatcher.d.ts.map +0 -1
- package/dist/services/fileWatcher.js +0 -120
- package/dist/services/fileWatcher.js.map +0 -1
- package/dist/services/gitOperations.d.ts +0 -3
- package/dist/services/gitOperations.d.ts.map +0 -1
- package/dist/services/gitOperations.js +0 -43
- package/dist/services/gitOperations.js.map +0 -1
- package/dist/services/socialPosting.d.ts +0 -38
- package/dist/services/socialPosting.d.ts.map +0 -1
- package/dist/services/socialPosting.js +0 -102
- package/dist/services/socialPosting.js.map +0 -1
- package/dist/services/transcription.d.ts +0 -3
- package/dist/services/transcription.d.ts.map +0 -1
- package/dist/services/transcription.js +0 -100
- package/dist/services/transcription.js.map +0 -1
- package/dist/services/videoIngestion.d.ts +0 -3
- package/dist/services/videoIngestion.d.ts.map +0 -1
- package/dist/services/videoIngestion.js +0 -104
- package/dist/services/videoIngestion.js.map +0 -1
- package/dist/tools/captions/captionGenerator.d.ts +0 -84
- package/dist/tools/captions/captionGenerator.d.ts.map +0 -1
- package/dist/tools/captions/captionGenerator.js +0 -390
- package/dist/tools/captions/captionGenerator.js.map +0 -1
- package/dist/tools/ffmpeg/aspectRatio.d.ts +0 -101
- package/dist/tools/ffmpeg/aspectRatio.d.ts.map +0 -1
- package/dist/tools/ffmpeg/aspectRatio.js +0 -339
- package/dist/tools/ffmpeg/aspectRatio.js.map +0 -1
- package/dist/tools/ffmpeg/audioExtraction.d.ts +0 -16
- package/dist/tools/ffmpeg/audioExtraction.d.ts.map +0 -1
- package/dist/tools/ffmpeg/audioExtraction.js +0 -87
- package/dist/tools/ffmpeg/audioExtraction.js.map +0 -1
- package/dist/tools/ffmpeg/captionBurning.d.ts +0 -8
- package/dist/tools/ffmpeg/captionBurning.d.ts.map +0 -1
- package/dist/tools/ffmpeg/captionBurning.js +0 -72
- package/dist/tools/ffmpeg/captionBurning.js.map +0 -1
- package/dist/tools/ffmpeg/clipExtraction.d.ts +0 -38
- package/dist/tools/ffmpeg/clipExtraction.d.ts.map +0 -1
- package/dist/tools/ffmpeg/clipExtraction.js +0 -215
- package/dist/tools/ffmpeg/clipExtraction.js.map +0 -1
- package/dist/tools/ffmpeg/faceDetection.d.ts +0 -127
- package/dist/tools/ffmpeg/faceDetection.d.ts.map +0 -1
- package/dist/tools/ffmpeg/faceDetection.js +0 -501
- package/dist/tools/ffmpeg/faceDetection.js.map +0 -1
- package/dist/tools/ffmpeg/frameCapture.d.ts +0 -10
- package/dist/tools/ffmpeg/frameCapture.d.ts.map +0 -1
- package/dist/tools/ffmpeg/frameCapture.js +0 -49
- package/dist/tools/ffmpeg/frameCapture.js.map +0 -1
- package/dist/tools/ffmpeg/silenceDetection.d.ts +0 -10
- package/dist/tools/ffmpeg/silenceDetection.d.ts.map +0 -1
- package/dist/tools/ffmpeg/silenceDetection.js +0 -56
- package/dist/tools/ffmpeg/silenceDetection.js.map +0 -1
- package/dist/tools/ffmpeg/singlePassEdit.d.ts +0 -25
- package/dist/tools/ffmpeg/singlePassEdit.d.ts.map +0 -1
- package/dist/tools/ffmpeg/singlePassEdit.js +0 -124
- package/dist/tools/ffmpeg/singlePassEdit.js.map +0 -1
- package/dist/tools/search/exaClient.d.ts +0 -8
- package/dist/tools/search/exaClient.d.ts.map +0 -1
- package/dist/tools/search/exaClient.js +0 -38
- package/dist/tools/search/exaClient.js.map +0 -1
- package/dist/tools/whisper/whisperClient.d.ts +0 -3
- package/dist/tools/whisper/whisperClient.d.ts.map +0 -1
- package/dist/tools/whisper/whisperClient.js +0 -77
- package/dist/tools/whisper/whisperClient.js.map +0 -1
- package/dist/types/index.d.ts +0 -305
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -44
- 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>
|