diff --git a/.gitignore b/.gitignore index 01c049b..c001ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,11 @@ jspm_packages/ # Aider AI Chat .aider* vms/public/frontend/assets +vms/public/frontend/index.html +vms/public/frontend/sw.js +vms/public/frontend/workbox-*.js +vms/public/frontend/manifest.webmanifest +vms/www/vms.html # Playwright e2e/.auth/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eca68ad..4456c0d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,7 @@ repos: exclude: | (?x)^( vms/public/dist/.*| + vms/public/frontend/.*| .*node_modules.*| .*boilerplate.*| vms/templates/includes/.*| @@ -57,6 +58,7 @@ repos: exclude: | (?x)^( vms/public/dist/.*| + vms/public/frontend/.*| cypress/.*| .*node_modules.*| .*boilerplate.*| diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 5933baf..c9172dd 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -1,5 +1,5 @@ import { HugeiconsIcon } from "@hugeicons/react" -import { Settings01Icon, UserGroupIcon, UserCircleIcon, SubtitleIcon } from "@hugeicons/core-free-icons" +import { Settings01Icon, UserGroupIcon, UserCircleIcon, SubtitleIcon, YoutubeIcon } from "@hugeicons/core-free-icons" import { Dialog, DialogContent, @@ -20,11 +20,13 @@ import { ProfileSection } from "@/components/settings/ProfileSection" import { GeneralSection } from "@/components/settings/GeneralSection" import { UsersSection } from "@/components/settings/UsersSection" import { TranscriptionSection } from "@/components/settings/TranscriptionSection" +import { YouTubeSection } from "@/components/settings/YouTubeSection" const sections = [ { id: "profile", label: "Profile", icon: UserCircleIcon }, { id: "general", label: "General", icon: Settings01Icon }, { id: "transcription", label: "Transcription", icon: SubtitleIcon }, + { id: "youtube", label: "YouTube", icon: YoutubeIcon }, { id: "users", label: "Users", icon: UserGroupIcon }, ] as const @@ -111,6 +113,9 @@ function SettingsContent({ + + + @@ -126,6 +131,9 @@ function SettingsContent({ + + + diff --git a/frontend/src/components/review/ReviewHeader.tsx b/frontend/src/components/review/ReviewHeader.tsx index f6400fb..02d030b 100644 --- a/frontend/src/components/review/ReviewHeader.tsx +++ b/frontend/src/components/review/ReviewHeader.tsx @@ -1,7 +1,7 @@ import { useState } from "react" import { useNavigate } from "react-router" import { HugeiconsIcon } from "@hugeicons/react" -import { ArrowLeft01Icon, Download04Icon, Link01Icon, Copy01Icon, SubtitleIcon, Scissor01Icon, GitForkIcon, Video01Icon } from "@hugeicons/core-free-icons" +import { ArrowLeft01Icon, Download04Icon, Link01Icon, Copy01Icon, SubtitleIcon, Scissor01Icon, GitForkIcon, Video01Icon, YoutubeIcon } from "@hugeicons/core-free-icons" import { Button, buttonVariants } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Switch } from "@/components/ui/switch" @@ -34,6 +34,10 @@ interface ReviewHeaderProps { proxyStatus?: string onGenerateProxy?: () => Promise isGeneratingProxy?: boolean + youtubeUploadStatus?: string + youtubeVideoUrl?: string + onOpenYouTubeUpload?: () => void + onResetYouTubeUpload?: () => void } export function ReviewHeader({ @@ -57,6 +61,10 @@ export function ReviewHeader({ proxyStatus, onGenerateProxy, isGeneratingProxy, + youtubeUploadStatus, + youtubeVideoUrl, + onOpenYouTubeUpload, + onResetYouTubeUpload, }: ReviewHeaderProps) { const navigate = useNavigate() const { isGuest, token } = useReviewContext() @@ -180,6 +188,71 @@ export function ReviewHeader({ Proxy )} + {/* YouTube button — auth users only, video only */} + {!isGuest && isVideo && (() => { + if (youtubeUploadStatus === "Queued" || youtubeUploadStatus === "Uploading") { + return ( + + ) + } + if (youtubeUploadStatus === "Complete" && youtubeVideoUrl) { + return ( +
+ + + + YouTube + + + +
+ ) + } + if (youtubeUploadStatus === "Error") { + return ( + + ) + } + return ( + <> + + + + ) + })()} + {/* Share button — auth users only */} {!isGuest && ( diff --git a/frontend/src/components/review/YouTubeUploadDialog.tsx b/frontend/src/components/review/YouTubeUploadDialog.tsx new file mode 100644 index 0000000..bb29b23 --- /dev/null +++ b/frontend/src/components/review/YouTubeUploadDialog.tsx @@ -0,0 +1,245 @@ +import { useState } from "react" +import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Progress } from "@/components/ui/progress" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +interface YouTubeUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + assetName: string + fileName: string + uploadStatus: string + uploadStage: string + uploadPercent: number + uploadError: string + uploadVideoUrl: string + onUploadStarted: () => void +} + +export function YouTubeUploadDialog({ + open, + onOpenChange, + assetName, + fileName, + uploadStatus, + uploadStage, + uploadPercent, + uploadError, + uploadVideoUrl, + onUploadStarted, +}: YouTubeUploadDialogProps) { + const [title, setTitle] = useState(fileName.replace(/\.[^/.]+$/, "")) + const [description, setDescription] = useState("") + const [privacyStatus, setPrivacyStatus] = useState("unlisted") + + const { data: statusData } = useFrappeGetCall<{ + message: { connected: boolean; channel_name: string } + }>("vms.youtube.get_youtube_status", undefined, "youtube-status-check", { + revalidateOnFocus: false, + }) + + const { call: callUpload, loading: uploading } = useFrappePostCall( + "vms.youtube.upload_to_youtube" + ) + + const isConnected = statusData?.message?.connected + const isInProgress = uploadStatus === "Queued" || uploadStatus === "Uploading" + const isComplete = uploadStatus === "Complete" + const isError = uploadStatus === "Error" + + const handleUpload = async () => { + if (!title.trim()) { + toast.error("Title is required") + return + } + + try { + await callUpload({ + asset_name: assetName, + title: title.trim(), + description: description.trim(), + privacy_status: privacyStatus, + }) + onUploadStarted() + } catch (e: unknown) { + const message = e instanceof Error ? e.message : "Failed to start upload" + toast.error(message) + } + } + + const stageLabel = uploadStage === "downloading" + ? "Downloading from storage..." + : uploadStage === "uploading" + ? "Uploading to YouTube..." + : uploadStage === "queued" + ? "Queued, waiting to start..." + : "Processing..." + + return ( + { if (!isInProgress) onOpenChange(v) }}> + { if (isInProgress) e.preventDefault() }}> + + Upload to YouTube + + {isConnected + ? `Uploading to ${statusData?.message?.channel_name || "YouTube"}` + : "YouTube is not connected"} + + + + {isConnected === false ? ( +
+

+ Connect your YouTube account in Settings to upload videos. +

+ +
+ ) : isInProgress ? ( +
+
+
+ {stageLabel} + {uploadPercent}% +
+ +
+

+ You can close this dialog — the upload will continue in the background. +

+ + + +
+ ) : isComplete ? ( +
+
+
+

Upload complete

+
+ + {uploadVideoUrl && ( + + )} + + +
+ ) : isError ? ( +
+
+
+
+

Upload failed

+ {uploadError && ( +

{uploadError}

+ )} +
+
+ + + + +
+ ) : ( + <> +
+
+ + setTitle(e.target.value)} + maxLength={100} + /> +
+ +
+ +