Skip to content

Commit 5898063

Browse files
fix: add progress UI to YouTube upload dialog and fix realtime delivery
- Show inline progress bar with percentage in upload dialog instead of closing immediately after starting upload - Dialog now has 4 states: form, in-progress, complete, error - Use realtime events (useFrappeEventListener) for instant progress updates with fallback 5s polling for reliability - Remove doctype/docname scoping from publish_realtime calls so events reach the site room that useFrappeEventListener listens on - Switch YouTube job queue from "long" to "default" (no long worker in Procfile) - Remove duplicate import requests in youtube.py Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 8ad2876 commit 5898063

6 files changed

Lines changed: 160 additions & 52 deletions

File tree

frontend/src/components/review/YouTubeUploadDialog.tsx

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"
1313
import { Input } from "@/components/ui/input"
1414
import { Label } from "@/components/ui/label"
1515
import { Textarea } from "@/components/ui/textarea"
16+
import { Progress } from "@/components/ui/progress"
1617
import {
1718
Select,
1819
SelectContent,
@@ -26,6 +27,11 @@ interface YouTubeUploadDialogProps {
2627
onOpenChange: (open: boolean) => void
2728
assetName: string
2829
fileName: string
30+
uploadStatus: string
31+
uploadStage: string
32+
uploadPercent: number
33+
uploadError: string
34+
uploadVideoUrl: string
2935
onUploadStarted: () => void
3036
}
3137

@@ -34,6 +40,11 @@ export function YouTubeUploadDialog({
3440
onOpenChange,
3541
assetName,
3642
fileName,
43+
uploadStatus,
44+
uploadStage,
45+
uploadPercent,
46+
uploadError,
47+
uploadVideoUrl,
3748
onUploadStarted,
3849
}: YouTubeUploadDialogProps) {
3950
const [title, setTitle] = useState(fileName.replace(/\.[^/.]+$/, ""))
@@ -51,6 +62,9 @@ export function YouTubeUploadDialog({
5162
)
5263

5364
const isConnected = statusData?.message?.connected
65+
const isInProgress = uploadStatus === "Queued" || uploadStatus === "Uploading"
66+
const isComplete = uploadStatus === "Complete"
67+
const isError = uploadStatus === "Error"
5468

5569
const handleUpload = async () => {
5670
if (!title.trim()) {
@@ -65,18 +79,24 @@ export function YouTubeUploadDialog({
6579
description: description.trim(),
6680
privacy_status: privacyStatus,
6781
})
68-
toast.success("YouTube upload started")
69-
onOpenChange(false)
7082
onUploadStarted()
7183
} catch (e: unknown) {
7284
const message = e instanceof Error ? e.message : "Failed to start upload"
7385
toast.error(message)
7486
}
7587
}
7688

89+
const stageLabel = uploadStage === "downloading"
90+
? "Downloading from storage..."
91+
: uploadStage === "uploading"
92+
? "Uploading to YouTube..."
93+
: uploadStage === "queued"
94+
? "Queued, waiting to start..."
95+
: "Processing..."
96+
7797
return (
78-
<Dialog open={open} onOpenChange={onOpenChange}>
79-
<DialogContent className="sm:max-w-md">
98+
<Dialog open={open} onOpenChange={(v) => { if (!isInProgress) onOpenChange(v) }}>
99+
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => { if (isInProgress) e.preventDefault() }}>
80100
<DialogHeader>
81101
<DialogTitle>Upload to YouTube</DialogTitle>
82102
<DialogDescription>
@@ -95,7 +115,6 @@ export function YouTubeUploadDialog({
95115
variant="outline"
96116
onClick={() => {
97117
onOpenChange(false)
98-
// Dispatch event to open settings to youtube tab
99118
window.dispatchEvent(
100119
new CustomEvent("open-settings", { detail: { tab: "youtube" } })
101120
)
@@ -104,6 +123,63 @@ export function YouTubeUploadDialog({
104123
Open Settings
105124
</Button>
106125
</div>
126+
) : isInProgress ? (
127+
<div className="py-4 space-y-3">
128+
<div className="space-y-2">
129+
<div className="flex items-center justify-between text-xs text-muted-foreground">
130+
<span>{stageLabel}</span>
131+
<span>{uploadPercent}%</span>
132+
</div>
133+
<Progress value={uploadPercent} className="h-2" />
134+
</div>
135+
<p className="text-xs text-muted-foreground text-center">
136+
You can close this dialog — the upload will continue in the background.
137+
</p>
138+
<DialogFooter>
139+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
140+
Close
141+
</Button>
142+
</DialogFooter>
143+
</div>
144+
) : isComplete ? (
145+
<div className="py-4 space-y-3">
146+
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
147+
<div className="size-2 rounded-full bg-green-500 shrink-0" />
148+
<p className="text-sm font-medium">Upload complete</p>
149+
</div>
150+
<DialogFooter>
151+
{uploadVideoUrl && (
152+
<Button variant="outline" size="sm" asChild>
153+
<a href={uploadVideoUrl} target="_blank" rel="noopener noreferrer">
154+
View on YouTube
155+
</a>
156+
</Button>
157+
)}
158+
<Button size="sm" onClick={() => onOpenChange(false)}>
159+
Done
160+
</Button>
161+
</DialogFooter>
162+
</div>
163+
) : isError ? (
164+
<div className="py-4 space-y-3">
165+
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5">
166+
<div className="size-2 rounded-full bg-destructive shrink-0" />
167+
<div className="min-w-0 flex-1">
168+
<p className="text-sm font-medium">Upload failed</p>
169+
{uploadError && (
170+
<p className="text-xs text-muted-foreground truncate">{uploadError}</p>
171+
)}
172+
</div>
173+
</div>
174+
<DialogFooter>
175+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
176+
Close
177+
</Button>
178+
<Button size="sm" onClick={handleUpload} disabled={uploading}>
179+
Retry
180+
</Button>
181+
</DialogFooter>
182+
</div>
107183
) : (
108184
<>
109185
<div className="space-y-4">

frontend/src/pages/ReviewPage.tsx

Lines changed: 73 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useState } from "react"
22
import { useParams, useSearchParams } from "react-router"
3-
import { useFrappeGetCall, useFrappePostCall, useFrappeAuth } from "frappe-react-sdk"
3+
import { useFrappeGetCall, useFrappePostCall, useFrappeAuth, useFrappeEventListener } from "frappe-react-sdk"
44
import { Spinner } from "@/components/ui/spinner"
55
import { ReviewProvider } from "@/contexts/ReviewContext"
66
import { useReviewContext } from "@/hooks/useReviewContext"
@@ -165,25 +165,76 @@ function ReviewPageInner({
165165

166166
const proxyStatus = proxyStatusData?.message?.proxy_status || asset.proxy_status || ""
167167

168-
// YouTube upload polling
169-
const [isYouTubePolling, setIsYouTubePolling] = useState(
170-
asset.youtube_upload_status === "Queued" || asset.youtube_upload_status === "Uploading"
171-
)
168+
// YouTube upload — realtime + fallback polling
169+
const [youtubeProgress, setYoutubeProgress] = useState<{
170+
status: string
171+
videoUrl: string
172+
percent: number
173+
stage: string
174+
error: string
175+
}>({
176+
status: asset.youtube_upload_status || "",
177+
videoUrl: asset.youtube_video_url || "",
178+
percent: 0,
179+
stage: "",
180+
error: "",
181+
})
182+
183+
const isYouTubeActive = youtubeProgress.status === "Queued" || youtubeProgress.status === "Uploading"
184+
185+
// Realtime listener for instant progress
186+
useFrappeEventListener<{
187+
asset_name: string
188+
stage: string
189+
percent: number
190+
video_url?: string
191+
error?: string
192+
}>("youtube_upload_progress", useCallback((data) => {
193+
if (data.asset_name !== asset.name) return
194+
if (data.stage === "complete") {
195+
setYoutubeProgress({ status: "Complete", videoUrl: data.video_url || "", percent: 100, stage: "complete", error: "" })
196+
mutateReviewData()
197+
} else if (data.stage === "error") {
198+
setYoutubeProgress((prev) => ({ ...prev, status: "Error", stage: "error", error: data.error || "Upload failed" }))
199+
mutateReviewData()
200+
} else {
201+
setYoutubeProgress((prev) => ({
202+
...prev,
203+
status: "Uploading",
204+
stage: data.stage,
205+
percent: data.percent,
206+
}))
207+
}
208+
}, [asset.name, mutateReviewData]))
172209

173-
const { data: youtubeStatusData } = useFrappeGetCall<{
210+
// Fallback polling in case realtime events don't arrive
211+
const { data: youtubeStatusPoll } = useFrappeGetCall<{
174212
message: { youtube_upload_status: string; youtube_video_id: string; youtube_video_url: string }
175213
}>(
176214
"vms.youtube.get_youtube_upload_status",
177-
isYouTubePolling ? { asset_name: asset.name } : undefined,
178-
isYouTubePolling ? `youtube-status-${asset.name}` : undefined,
179-
{
180-
revalidateOnFocus: false,
181-
refreshInterval: isYouTubePolling ? 5000 : 0,
182-
},
215+
isYouTubeActive ? { asset_name: asset.name } : undefined,
216+
isYouTubeActive ? `youtube-poll-${asset.name}` : undefined,
217+
{ revalidateOnFocus: false, refreshInterval: isYouTubeActive ? 5000 : 0 },
183218
)
184219

185-
const youtubeUploadStatus = youtubeStatusData?.message?.youtube_upload_status || asset.youtube_upload_status || ""
186-
const youtubeVideoUrl = youtubeStatusData?.message?.youtube_video_url || asset.youtube_video_url || ""
220+
// Sync poll results into state (only if realtime hasn't already updated)
221+
useEffect(() => {
222+
const polled = youtubeStatusPoll?.message
223+
if (!polled) return
224+
const pollStatus = polled.youtube_upload_status
225+
if (pollStatus === "Complete" && youtubeProgress.status !== "Complete") {
226+
setYoutubeProgress({ status: "Complete", videoUrl: polled.youtube_video_url || "", percent: 100, stage: "complete", error: "" })
227+
mutateReviewData()
228+
} else if (pollStatus === "Error" && youtubeProgress.status !== "Error") {
229+
setYoutubeProgress((prev) => ({ ...prev, status: "Error", stage: "error", error: "Upload failed" }))
230+
mutateReviewData()
231+
} else if (pollStatus === "Uploading" && youtubeProgress.status === "Queued") {
232+
setYoutubeProgress((prev) => ({ ...prev, status: "Uploading", stage: "uploading" }))
233+
}
234+
}, [youtubeStatusPoll?.message]) // eslint-disable-line react-hooks/exhaustive-deps
235+
236+
const youtubeUploadStatus = youtubeProgress.status
237+
const youtubeVideoUrl = youtubeProgress.videoUrl
187238

188239
// Stop proxy polling when done
189240
useEffect(() => {
@@ -195,18 +246,7 @@ function ReviewPageInner({
195246
}
196247
}, [proxyStatus])
197248

198-
// Stop YouTube polling when done
199-
useEffect(() => {
200-
if (youtubeUploadStatus === "Complete" || youtubeUploadStatus === "Error") {
201-
setIsYouTubePolling(false)
202-
mutateReviewData()
203-
if (youtubeUploadStatus === "Complete") {
204-
toast.success("Video uploaded to YouTube!")
205-
} else if (youtubeUploadStatus === "Error") {
206-
toast.error("YouTube upload failed. You can retry.")
207-
}
208-
}
209-
}, [youtubeUploadStatus, mutateReviewData])
249+
210250

211251
const handleGenerateProxy = useCallback(async () => {
212252
await callGenerateProxy({ asset_name: asset.name })
@@ -274,6 +314,7 @@ function ReviewPageInner({
274314

275315
const handleResetYouTubeUpload = useCallback(async () => {
276316
await callResetYouTubeUpload({ asset_name: asset.name })
317+
setYoutubeProgress({ status: "", videoUrl: "", percent: 0, stage: "", error: "" })
277318
mutateReviewData()
278319
setYoutubeDialogOpen(true)
279320
}, [asset.name, callResetYouTubeUpload, mutateReviewData])
@@ -366,8 +407,13 @@ function ReviewPageInner({
366407
onOpenChange={setYoutubeDialogOpen}
367408
assetName={asset.name}
368409
fileName={asset.file_name}
410+
uploadStatus={youtubeUploadStatus}
411+
uploadStage={youtubeProgress.stage}
412+
uploadPercent={youtubeProgress.percent}
413+
uploadError={youtubeProgress.error}
414+
uploadVideoUrl={youtubeVideoUrl}
369415
onUploadStarted={() => {
370-
setIsYouTubePolling(true)
416+
setYoutubeProgress({ status: "Queued", videoUrl: "", percent: 0, stage: "queued", error: "" })
371417
mutateReviewData()
372418
}}
373419
/>

vms/public/frontend/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<meta name="apple-mobile-web-app-capable" content="yes" />
1010
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
1111
<title>BWH VMS</title>
12-
<script type="module" crossorigin src="/assets/vms/frontend/assets/index-lNBSaCb8.js"></script>
13-
<link rel="stylesheet" crossorigin href="/assets/vms/frontend/assets/index-iRNwJROS.css">
12+
<script type="module" crossorigin src="/assets/vms/frontend/assets/index-CE69mJ0e.js"></script>
13+
<link rel="stylesheet" crossorigin href="/assets/vms/frontend/assets/index-wapHyRJt.css">
1414
<link rel="manifest" href="/assets/vms/frontend/manifest.webmanifest"></head>
1515
<body>
1616
<div id="root"></div>

vms/public/frontend/sw.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vms/www/vms.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<meta name="apple-mobile-web-app-capable" content="yes" />
1010
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
1111
<title>BWH VMS</title>
12-
<script type="module" crossorigin src="/assets/vms/frontend/assets/index-lNBSaCb8.js"></script>
13-
<link rel="stylesheet" crossorigin href="/assets/vms/frontend/assets/index-iRNwJROS.css">
12+
<script type="module" crossorigin src="/assets/vms/frontend/assets/index-CE69mJ0e.js"></script>
13+
<link rel="stylesheet" crossorigin href="/assets/vms/frontend/assets/index-wapHyRJt.css">
1414
<link rel="manifest" href="/assets/vms/frontend/manifest.webmanifest"></head>
1515
<body>
1616
<div id="root"></div>

0 commit comments

Comments
 (0)