-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add YouTube URL input to media screen video section (NES-1369) #8799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,18 @@ import { | |
| import { VideoBlockSource } from '../../../../../../../../__generated__/globalTypes' | ||
| import { getJourneyMedia } from '../../../../../utils/getJourneyMedia' | ||
|
|
||
| const YOUTUBE_ID_REGEX = /(\/|%3D|vi=|v=)([0-9A-Za-z-_]{11})([%#?&/]|$)/ | ||
|
|
||
| /** | ||
| * Extracts an 11-character YouTube video ID from a URL. | ||
| * | ||
| * Supports standard watch URLs, youtu.be short links, shorts, and embed URLs. | ||
| * Returns null when no valid ID can be found. | ||
| */ | ||
| export function extractYouTubeVideoId(url: string): string | null { | ||
| return url.match(YOUTUBE_ID_REGEX)?.[2] ?? null | ||
|
Comment on lines
+8
to
+17
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict extraction to YouTube hosts to avoid false positives. Line 8 currently matches IDs from non-YouTube URLs (e.g., any domain containing 🔧 Proposed fix-const YOUTUBE_ID_REGEX = /(\/|%3D|vi=|v=)([0-9A-Za-z-_]{11})([%#?&/]|$)/
+const YOUTUBE_ID_REGEX = /^[0-9A-Za-z_-]{11}$/
+const YOUTUBE_HOST_REGEX = /(^|\.)youtube\.com$|(^|\.)youtu\.be$/
export function extractYouTubeVideoId(url: string): string | null {
- return url.match(YOUTUBE_ID_REGEX)?.[2] ?? null
+ try {
+ const parsed = new URL(url.trim())
+ const host = parsed.hostname.toLowerCase()
+ if (!YOUTUBE_HOST_REGEX.test(host)) return null
+
+ const candidate =
+ host.endsWith('youtu.be')
+ ? parsed.pathname.split('/').filter(Boolean)[0]
+ : parsed.searchParams.get('v') ??
+ parsed.pathname.split('/').filter(Boolean).at(-1)
+
+ return candidate != null && YOUTUBE_ID_REGEX.test(candidate)
+ ? candidate
+ : null
+ } catch {
+ return null
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Returns the first customizable video block that belongs to the given card. | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { useMutation } from '@apollo/client' | ||
| import { useTranslation } from 'next-i18next' | ||
| import { useSnackbar } from 'notistack' | ||
| import { Dispatch, RefObject, SetStateAction, useCallback } from 'react' | ||
|
|
||
| import { useJourney } from '@core/journeys/ui/JourneyProvider' | ||
| import { GET_JOURNEY } from '@core/journeys/ui/useJourneyQuery' | ||
|
|
||
| import { | ||
| IdType, | ||
| VideoBlockSource | ||
| } from '../../../../../__generated__/globalTypes' | ||
| import { VIDEO_BLOCK_UPDATE } from '../../../Editor/Slider/Settings/CanvasDetails/Properties/blocks/Video/Options/VideoOptions' | ||
|
|
||
| import type { UploadTaskInternal } from './types' | ||
|
|
||
| interface UseYouTubeVideoLinkingParams { | ||
| setUploadTasks: Dispatch<SetStateAction<Map<string, UploadTaskInternal>>> | ||
| updateTask: ( | ||
| videoBlockId: string, | ||
| updates: Partial<UploadTaskInternal> | ||
| ) => void | ||
| removeTask: (videoBlockId: string) => void | ||
| activeBlocksRef: RefObject<Set<string>> | ||
| } | ||
|
|
||
| /** | ||
| * Manages persisting a YouTube video URL to a video block via VIDEO_BLOCK_UPDATE. | ||
| * | ||
| * Unlike Mux uploads there is no file transfer or polling — just a single | ||
| * mutation call. The block is marked as 'updating' for the duration so that | ||
| * `hasActiveUploads` stays true, preventing navigation away from the media screen. | ||
| */ | ||
| export function useYouTubeVideoLinking({ | ||
| setUploadTasks, | ||
| updateTask, | ||
| removeTask, | ||
| activeBlocksRef | ||
| }: UseYouTubeVideoLinkingParams): { | ||
| linkYouTubeVideo: (videoBlockId: string, youtubeVideoId: string) => Promise<void> | ||
| } { | ||
| const { t } = useTranslation('apps-journeys-admin') | ||
| const { enqueueSnackbar } = useSnackbar() | ||
| const { journey } = useJourney() | ||
|
|
||
| const [videoBlockUpdate] = useMutation(VIDEO_BLOCK_UPDATE) | ||
|
|
||
| const linkYouTubeVideo = useCallback( | ||
| async (videoBlockId: string, youtubeVideoId: string) => { | ||
| if (activeBlocksRef.current.has(videoBlockId)) return | ||
| if (journey?.id == null) return | ||
|
|
||
| activeBlocksRef.current.add(videoBlockId) | ||
| setUploadTasks((prev) => { | ||
| const next = new Map(prev) | ||
| next.set(videoBlockId, { | ||
| videoBlockId, | ||
| status: 'updating', | ||
| progress: 0, | ||
| retryCount: 0 | ||
| }) | ||
| return next | ||
| }) | ||
|
|
||
| try { | ||
| await videoBlockUpdate({ | ||
| variables: { | ||
| id: videoBlockId, | ||
| input: { | ||
| videoId: youtubeVideoId, | ||
| source: VideoBlockSource.youTube | ||
| } | ||
| }, | ||
| refetchQueries: [ | ||
| { | ||
| query: GET_JOURNEY, | ||
| variables: { | ||
| id: journey.id, | ||
| idType: IdType.databaseId, | ||
| options: { skipRoutingFilter: true } | ||
| } | ||
| } | ||
| ] | ||
| }) | ||
| enqueueSnackbar(t('YouTube video set successfully'), { | ||
| variant: 'success', | ||
| autoHideDuration: 2000 | ||
| }) | ||
| removeTask(videoBlockId) | ||
| } catch { | ||
| enqueueSnackbar(t('Failed to set YouTube video. Please try again'), { | ||
| variant: 'error', | ||
| autoHideDuration: 2000 | ||
| }) | ||
| updateTask(videoBlockId, { | ||
| status: 'error', | ||
| error: 'Failed to set YouTube video. Please try again' | ||
| }) | ||
| } finally { | ||
| activeBlocksRef.current.delete(videoBlockId) | ||
| } | ||
| }, | ||
| [ | ||
| activeBlocksRef, | ||
| journey?.id, | ||
| videoBlockUpdate, | ||
| enqueueSnackbar, | ||
| t, | ||
| setUploadTasks, | ||
| updateTask, | ||
| removeTask | ||
| ] | ||
| ) | ||
|
|
||
| return { linkYouTubeVideo } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve the URL until linking succeeds.
Line 151 clears
youtubeUrlbefore the async link attempt completes. If the mutation fails, users lose the pasted URL and need to re-enter it.🤖 Prompt for AI Agents